fix(tui): correct project-aware session lists

This commit is contained in:
Dax Raad 2026-07-29 21:58:53 -04:00
commit 488445a679
38 changed files with 506 additions and 220 deletions

View file

@ -102,12 +102,12 @@ export function DialogIntegration(
title="Connect a service"
options={options()}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No integrations available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No integrations found</text>
</box>
}

View file

@ -328,7 +328,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
options={options()}
emptyView={
showError() ? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load project directories
</text>
@ -336,17 +336,17 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
<text fg={theme.text.subdued}>Close and reopen Move session to try again.</text>
</box>
) : directories.loading || loadedProject.loading ? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>Loading project directories</text>
</box>
) : (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No project directories available</text>
</box>
)
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No project directories found</text>
</box>
}

View file

@ -1,6 +1,7 @@
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import path from "path"
import type { SessionInfo } from "@opencode-ai/client"
import { TextAttributes } from "@opentui/core"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
@ -32,52 +33,69 @@ export function DialogSessionList() {
const shortcuts = Keymap.useShortcuts()
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
const [allProjects, setAllProjects] = createSignal(false)
const [searchResults] = createResource(search, async (query) => {
if (!query) return
try {
if (!data.location.info()) await data.location.sync()
const current = data.location.info()
if (!current) throw new Error("Location unavailable")
const response = await client.api.session.list({
project: current.project.id,
search: query,
limit: 50,
order: "desc",
parentID: null,
})
return { query, sessions: response.data, error: undefined }
} catch (error) {
// A transient transport failure must degrade search, not crash the TUI
// through the root ErrorBoundary when the errored resource is read.
return { query, sessions: [] as SessionInfo[], error }
}
})
const [searchResults, { mutate: setSearchResults }] = createResource(
() => ({ query: search().trim(), allProjects: allProjects() }),
async ({ query, allProjects }) => {
try {
if (!data.location.info()) await data.location.sync()
const current = data.location.info()
if (!current) throw new Error("Location unavailable")
const response = await client.api.session.list({
...(allProjects
? {}
: {
project: current.project.id,
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"),
}),
...(query ? { search: query } : {}),
limit: 50,
order: "desc",
parentID: null,
})
return { query, allProjects, sessions: response.data, error: undefined }
} catch (error) {
// A transient transport failure must degrade search, not crash the TUI
// through the root ErrorBoundary when the errored resource is read.
return { query, allProjects, sessions: [] as SessionInfo[], error }
}
},
)
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const localSessions = createMemo(() => {
const query = filter().trim().toLowerCase()
const sessions = data.session.list()
const current = data.location.info()
const sessions = data.session
.list()
.filter(
(session) =>
allProjects() ||
(session.projectID === current?.project.id && session.location.directory === current.directory),
)
if (!query) return sessions
return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query))
})
const sessions = createMemo(() => {
const query = filter()
const query = filter().trim()
const local = localSessions()
if (!query) return local
if (query !== search() || searchResults.loading) return local
if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local
const result = searchResults()
if (result?.query !== query || result.error) return local
if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local
return result.sessions
})
const searchState = createMemo(() => {
const query = filter()
if (!query) return { message: "No sessions available", error: false }
if (query !== search() || searchResults.loading) return { message: "Searching sessions…", error: false }
const query = filter().trim()
if (query !== search().trim() || searchResults.loading)
return { message: query ? "Searching sessions…" : "Loading sessions…", error: false }
const result = searchResults()
if (result?.query === query && result.error)
return { message: "Could not search sessions. Change the search to try again.", error: true }
return { message: "No sessions found", error: false }
return {
message: query ? "Could not search sessions. Change the search to try again." : "Could not load sessions.",
error: true,
}
return { message: query ? "No sessions found" : "No sessions available", error: false }
})
const quickSwitchHint = createMemo(() => {
@ -91,6 +109,13 @@ export function DialogSessionList() {
const hint = quickSwitchHint()
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
})
const currentProjectName = createMemo(() => {
const current = data.location.info()
if (!current) return ""
const project = data.project.get(current.project.id)
if (!project) return ""
return project.name || path.basename(project.canonical)
})
const options = createMemo(() => {
const today = new Date().toDateString()
@ -105,8 +130,12 @@ export function DialogSessionList() {
const option = (session: SessionInfo, category: string) => {
const directory = session.location.directory
const footer =
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
const project = data.project.get(session.projectID)
const footer = allProjects()
? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20)
: directory !== data.location.info()?.project.directory
? Locale.truncate(path.basename(directory), 20)
: ""
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
@ -139,6 +168,16 @@ export function DialogSessionList() {
return (
<DialogSelect
title="Sessions"
titleView={
<box flexDirection="row">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Sessions
</text>
<Show when={!allProjects() && currentProjectName()}>
<text fg={theme.text.subdued}> for {currentProjectName()}</text>
</Show>
</box>
}
options={options()}
skipFilter={true}
current={currentSessionID()}
@ -146,13 +185,25 @@ export function DialogSessionList() {
setFilter(query)
setSearch(query)
}}
bindings={[
{
bind: "ctrl+a",
title: allProjects() ? "Show current directory sessions" : "Show all project sessions",
group: "Dialog",
run: () => {
setAllProjects((value) => !value)
},
},
]}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No sessions available</text>
<box paddingLeft={4} paddingRight={4}>
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
{searchState().message}
</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
{searchState().message}
</text>
@ -178,24 +229,34 @@ export function DialogSessionList() {
setToDelete(option.value)
return
}
void client.api.session.remove({ sessionID: option.value }).catch((error) => {
setToDelete(undefined)
toast.show({
message: `Failed to delete session: ${errorMessage(error)}`,
variant: "error",
duration: 5000,
void client.api.session
.remove({ sessionID: option.value })
.then(() => {
setSearchResults((result) =>
result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result,
)
})
.catch((error) => {
setToDelete(undefined)
toast.show({
message: `Failed to delete session: ${errorMessage(error)}`,
variant: "error",
duration: 5000,
})
})
})
},
},
{
command: "session.rename",
title: "rename",
onTrigger: (option: { value: string }) =>
DialogSessionRename.show(dialog, option.value, data.session.get(option.value)?.title),
onTrigger: (option: { value: string; title: string }) =>
DialogSessionRename.show(dialog, option.value, option.title),
},
]}
footerHints={quickSwitchFooterHints()}
footerHints={[
...quickSwitchFooterHints(),
{ title: allProjects() ? "current directory" : "all projects", label: "ctrl+a", side: "right" },
]}
/>
)
}

View file

@ -62,13 +62,13 @@ export function DialogSkill(props: DialogSkillProps) {
emptyView={
<Switch
fallback={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No skills available</text>
</box>
}
>
<Match when={showError()}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load skills
</text>
@ -77,14 +77,14 @@ export function DialogSkill(props: DialogSkillProps) {
</box>
</Match>
<Match when={skills.loading}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>Loading skills</text>
</box>
</Match>
</Switch>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No skills found</text>
</box>
}

View file

@ -15,6 +15,7 @@ import type {
ModelInfo,
PermissionSavedInfo,
PermissionRequest,
Project,
ProviderInfo,
ReferenceInfo,
SessionMessageInfo,
@ -80,6 +81,7 @@ type Store = {
form: Record<string, FormWithLocation[]>
}
project: {
info: Record<string, Project>
permission: Record<string, PermissionSavedInfo[]>
}
location: Record<string, LocationData>
@ -139,6 +141,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
form: {},
},
project: {
info: {},
permission: {},
},
location: {},
@ -954,10 +957,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
sync.invalidate(`session.pending:${sessionID}`)
},
},
sync(sessionID: string) {
return sync.run(`session:${sessionID}`, async () => {
setStore("session", "info", sessionID, await client.api.session.get({ sessionID }))
registerSession(sessionID)
sync(sessionID: string, options?: { children?: boolean }) {
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
const [info, children] = await Promise.all([
client.api.session.get({ sessionID }),
options?.children
? client.api.session.list({ parentID: sessionID, order: "desc" }).then((response) => response.data)
: [],
])
const sessions = [info, ...children]
setStore(
"session",
"info",
produce((draft) => {
for (const session of sessions) draft[session.id] = session
}),
)
for (const session of sessions) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
})
},
invalidate(sessionID: string) {
@ -1037,6 +1056,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
},
project: {
list() {
return Object.values(store.project.info).toSorted((a, b) => b.time.updated - a.time.updated)
},
get(projectID: string) {
return store.project.info[projectID]
},
sync() {
return sync.run("project", async () => {
const projects = await client.api.project.list()
setStore("project", "info", reconcile(Object.fromEntries(projects.map((project) => [project.id, project]))))
})
},
invalidate() {
sync.invalidate("project")
},
permission: {
list(projectID: string) {
return store.project.permission[projectID]
@ -1318,27 +1352,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
.then((location) => {
const key = locationKey(location)
setStore("location", key, { ...store.location[key], info: location })
return client.api.session.list({
project: location.project.id,
limit: 50,
order: "desc",
parentID: null,
})
})
.then((response) => {
setStore(
"session",
"info",
produce((draft) => {
for (const session of response.data) draft[session.id] = session
}),
)
for (const session of response.data) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
})
.catch((error) => console.error("Failed to preload sessions", error))
.catch((error) => console.error("Failed to preload location", error))
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
return
}
handleEvent(details)

View file

@ -256,7 +256,7 @@ export function Session() {
const sessionID = route.sessionID
void (async () => {
await Promise.all([
data.session.sync(sessionID),
data.session.sync(sessionID, { children: true }),
data.session.permission.sync(sessionID),
data.session.form.sync(sessionID),
])

View file

@ -615,14 +615,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
when={props.renderFilter !== false && store.filter.length > 0}
fallback={
props.emptyView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No items available</text>
</box>
)
}
>
{props.noMatchView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No results found</text>
</box>
)}

View file

@ -11,7 +11,7 @@ import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@ -71,11 +71,13 @@ function durable(sessionID: string, seq = 0, version = 1) {
return { aggregateID: sessionID, seq, version }
}
test("preloads root sessions before applying the session limit", async () => {
test("does not preload session summaries into the data context", async () => {
const events = createEventStream()
let request: URL | undefined
let location = false
let sessions = false
const calls = createFetch((url) => {
if (url.pathname === "/api/session") request = url
if (url.pathname === "/api/location") location = true
if (url.pathname === "/api/session") sessions = true
return undefined
}, events)
@ -92,10 +94,58 @@ test("preloads root sessions before applying the session limit", async () => {
))
try {
await wait(() => request !== undefined)
expect(request?.searchParams.get("project")).toBe("proj_test")
expect(request?.searchParams.get("limit")).toBe("50")
expect(request?.searchParams.get("parentID")).toBe("null")
await wait(() => location)
await Bun.sleep(20)
expect(sessions).toBe(false)
} finally {
app.renderer.destroy()
}
})
test("proactively syncs project metadata", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname !== "/api/project") return
return json([
{
id: "proj_test",
canonical: worktree,
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => data.project.get("proj_test") !== undefined)
expect(data.project.list()).toEqual([
{
id: "proj_test",
canonical: worktree,
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
} finally {
app.renderer.destroy()
}
@ -2619,8 +2669,18 @@ function sessionInfo(id: string, parentID: string | undefined, cost = 0) {
// the family-index tests below.
async function mountData(parents: Record<string, string>, costs: Record<string, number> = {}) {
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
const parentID = url.searchParams.get("parentID")
return json({
data: Object.entries(parents)
.filter(([, parent]) => parent === parentID)
.map(([id, parent]) => sessionInfo(id, parent, costs[id])),
cursor: {},
})
}
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
if (match && match[1] !== "active")
return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
})
let data!: ReturnType<typeof useData>
let ready!: () => void
@ -2647,6 +2707,20 @@ async function mountData(parents: Record<string, string>, costs: Record<string,
return { data, app }
}
test("syncs direct child session info with a navigated root", async () => {
const { data, app } = await mountData({ child: "root", sibling: "root", grandchild: "child" })
try {
await data.session.sync("root", { children: true })
expect(data.session.get("root")?.id).toBe("root")
expect(data.session.get("child")?.parentID).toBe("root")
expect(data.session.get("sibling")?.parentID).toBe("root")
expect(data.session.get("grandchild")).toBeUndefined()
expect(data.session.family("root")).toEqual(["root", "child", "sibling"])
} finally {
app.renderer.destroy()
}
})
test("groups an orphan child under its missing parent until the root arrives", async () => {
const { data, app } = await mountData({ child: "root" })
try {

View file

@ -185,6 +185,19 @@ test("dialog actions run without options while row actions still require a selec
}
})
test("renders one gap before an empty state", async () => {
await using tmp = await tmpdir()
const app = await renderSelect(tmp.path, [], () => {}, () => {})
try {
await app.waitForFrame((frame) => frame.includes("No items available"))
const lines = app.captureCharFrame().split("\n").map((line) => line.trim())
expect(lines.indexOf("No items available") - lines.indexOf("Search")).toBe(2)
} finally {
app.renderer.destroy()
}
})
test("footer actions run when filtering leaves no selected row", async () => {
await using tmp = await tmpdir()
let global = 0

View file

@ -93,24 +93,26 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: true })
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
if (url.pathname === "/api/location")
return json({ directory, project: { id: "proj_test", directory: worktree, canonical: worktree } })
if (url.pathname === "/api/fs/list")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
if (url.pathname === "/api/project") return json([])
if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }])
if (url.pathname === "/api/shell")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
if (url.pathname === "/api/mcp")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json({
location: { directory, project: { id: "proj_test", directory: worktree } },
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
data: { resources: [], templates: [] },
})
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/active") return json({ data: {} })
if (url.pathname === "/api/permission/request")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
if (url.pathname === "/api/form/request")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
@ -120,13 +122,13 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
)
)
return json({
location: { directory, project: { id: "proj_test", directory: worktree } },
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
data: [],
})
if (url.pathname === "/api/reference")
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory } }, data: [] })
if (url.pathname === "/api/websearch/provider") {
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory } }, data: [] })
}
if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
if (url.pathname === "/session") return json([])

View file

@ -78,7 +78,7 @@ describe("run interactive runtime", () => {
directory: "/tmp",
target: async () => ({
sessionID: "ses_root",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "build",
model: undefined,
variant: undefined,
@ -130,7 +130,7 @@ describe("run interactive runtime", () => {
await refreshCatalog?.()
expect(defaultModel).toHaveBeenCalledTimes(1)
selected.resolve({
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
data: model,
})
while (defaultModel.mock.calls.length < 2) await Bun.sleep(0)
@ -165,7 +165,7 @@ describe("run interactive runtime", () => {
directory: "/tmp",
target: async () => ({
sessionID: "ses_root",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "build",
model: { providerID: "test", modelID: "model" },
variant: undefined,
@ -261,7 +261,7 @@ describe("run interactive runtime", () => {
return {
sessionID: "ses-deferred",
sessionTitle: "Deferred",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
variant: undefined,
@ -349,7 +349,7 @@ describe("run interactive runtime", () => {
target: async () => ({
sessionID: "ses-resume",
sessionTitle: "Resume",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "review",
model: { providerID: "openai", modelID: "gpt-5" },
variant: "high",
@ -432,7 +432,7 @@ describe("run interactive runtime", () => {
target: async () => ({
sessionID: "ses-resume-abort",
sessionTitle: "Cached title",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp" } },
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "build",
model: undefined,
variant: undefined,
@ -490,7 +490,7 @@ describe("run interactive runtime", () => {
location: {
directory: "/session",
workspaceID: "work-1",
project: { id: "pro-1", directory: "/session" },
project: { id: "pro-1", directory: "/session", canonical: "/session" },
},
data: [{ path: "src/index.ts", type: "file" }],
} as never)
@ -507,7 +507,7 @@ describe("run interactive runtime", () => {
location: {
directory: "/session",
workspaceID: "work-1",
project: { id: "location-project", directory: "/session" },
project: { id: "location-project", directory: "/session", canonical: "/session" },
},
agent: "review",
model: { providerID: "openai", modelID: "gpt-5" },

View file

@ -167,7 +167,11 @@ function sdk(input: {
location: {
directory: input.globalLocation?.directory ?? "/tmp",
workspaceID: input.globalLocation?.workspaceID,
project: { id: "proj_1", directory: input.globalLocation?.directory ?? "/tmp" },
project: {
id: "proj_1",
directory: input.globalLocation?.directory ?? "/tmp",
canonical: input.globalLocation?.directory ?? "/tmp",
},
},
data: input.globals ?? [],
}),