feat(tui): maintain session family index in data context
This commit is contained in:
@@ -83,7 +83,7 @@ export function DialogSessionList() {
|
|||||||
category,
|
category,
|
||||||
footer,
|
footer,
|
||||||
gutter:
|
gutter:
|
||||||
data.session.status(session.id) === "running"
|
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||||
? () => <Spinner />
|
? () => <Spinner />
|
||||||
: slot === undefined
|
: slot === undefined
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -160,13 +160,12 @@ export function Prompt(props: PromptProps) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
||||||
const activeSubagents = createMemo(
|
const activeSubagents = createMemo(() => {
|
||||||
() =>
|
if (!props.sessionID) return 0
|
||||||
data.session
|
return data.session.family(props.sessionID).filter(
|
||||||
.list()
|
(id) => id !== props.sessionID && data.session.status(id) === "running",
|
||||||
.filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
|
).length
|
||||||
.length,
|
})
|
||||||
)
|
|
||||||
const runningShells = createMemo(
|
const runningShells = createMemo(
|
||||||
() => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length,
|
() => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ type LocationData = {
|
|||||||
type Data = {
|
type Data = {
|
||||||
session: {
|
session: {
|
||||||
info: Record<string, SessionV2Info>
|
info: Record<string, SessionV2Info>
|
||||||
|
// Family index keyed by a family's root (or furthest-known-ancestor when the
|
||||||
|
// true root is not yet loaded). The value is a flat deduplicated list of every
|
||||||
|
// session ID in that family, including the key itself once its info arrives.
|
||||||
|
family: Record<string, string[]>
|
||||||
status: Record<string, DataSessionStatus>
|
status: Record<string, DataSessionStatus>
|
||||||
message: Record<string, SessionMessage[]>
|
message: Record<string, SessionMessage[]>
|
||||||
permission: Record<string, PermissionV2Request[]>
|
permission: Record<string, PermissionV2Request[]>
|
||||||
@@ -77,6 +81,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
const [store, setStore] = createStore<Data>({
|
const [store, setStore] = createStore<Data>({
|
||||||
session: {
|
session: {
|
||||||
info: {},
|
info: {},
|
||||||
|
family: {},
|
||||||
status: {},
|
status: {},
|
||||||
message: {},
|
message: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
@@ -149,6 +154,46 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
return created
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Walk parentID upward through loaded session info to the family root. When a
|
||||||
|
// parent's info is missing, that missing ID is the furthest-known ancestor and
|
||||||
|
// is returned so orphan subtrees group under it until the parent arrives. A
|
||||||
|
// seen set guards against parent cycles, stopping at the last non-repeating
|
||||||
|
// ancestor.
|
||||||
|
function resolveRoot(sessionID: string) {
|
||||||
|
let current = sessionID
|
||||||
|
let parentID = store.session.info[sessionID]?.parentID
|
||||||
|
const seen = new Set([sessionID])
|
||||||
|
while (parentID) {
|
||||||
|
if (seen.has(parentID)) break
|
||||||
|
seen.add(parentID)
|
||||||
|
current = parentID
|
||||||
|
parentID = store.session.info[parentID]?.parentID
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register one session into the family index. Idempotent: refreshing an
|
||||||
|
// existing session never duplicates its ID. When a tentative family keyed by
|
||||||
|
// sessionID exists (descendants arrived while sessionID's own info was
|
||||||
|
// absent) but sessionID turns out to have a parent, fold the orphan subtree
|
||||||
|
// into the resolved root's family and drop the tentative entry.
|
||||||
|
function registerSession(sessionID: string) {
|
||||||
|
const info = store.session.info[sessionID]
|
||||||
|
if (!info) return
|
||||||
|
const rootID = resolveRoot(sessionID)
|
||||||
|
setStore("session", "family", produce((draft) => {
|
||||||
|
if (sessionID !== rootID && draft[sessionID]) {
|
||||||
|
const members = draft[rootID] ??= []
|
||||||
|
for (const id of draft[sessionID]) {
|
||||||
|
if (!members.includes(id)) members.push(id)
|
||||||
|
}
|
||||||
|
delete draft[sessionID]
|
||||||
|
}
|
||||||
|
const family = draft[rootID] ??= []
|
||||||
|
if (!family.includes(sessionID)) family.push(sessionID)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
function handleEvent(event: V2Event) {
|
function handleEvent(event: V2Event) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "session.created":
|
case "session.created":
|
||||||
@@ -599,11 +644,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
get(sessionID: string) {
|
get(sessionID: string) {
|
||||||
return store.session.info[sessionID]
|
return store.session.info[sessionID]
|
||||||
},
|
},
|
||||||
|
root(sessionID: string) {
|
||||||
|
return resolveRoot(sessionID)
|
||||||
|
},
|
||||||
|
family(sessionID: string) {
|
||||||
|
return store.session.family[resolveRoot(sessionID)] ?? []
|
||||||
|
},
|
||||||
status(sessionID: string) {
|
status(sessionID: string) {
|
||||||
return store.session.status[sessionID] ?? "idle"
|
return store.session.status[sessionID] ?? "idle"
|
||||||
},
|
},
|
||||||
async refresh(sessionID: string) {
|
async refresh(sessionID: string) {
|
||||||
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
|
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
|
||||||
|
registerSession(sessionID)
|
||||||
},
|
},
|
||||||
message: {
|
message: {
|
||||||
ids(sessionID: string) {
|
ids(sessionID: string) {
|
||||||
@@ -795,15 +847,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
directory: defaultLocation().directory,
|
directory: defaultLocation().directory,
|
||||||
workspace: defaultLocation().workspaceID,
|
workspace: defaultLocation().workspaceID,
|
||||||
})
|
})
|
||||||
.then((response) =>
|
.then((response) => {
|
||||||
setStore(
|
setStore(
|
||||||
"session",
|
"session",
|
||||||
"info",
|
"info",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
for (const session of response.data) draft[session.id] = mutable(session)
|
for (const session of response.data) draft[session.id] = mutable(session)
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
),
|
for (const session of response.data) registerSession(session.id)
|
||||||
|
}),
|
||||||
sdk.api.session
|
sdk.api.session
|
||||||
.active()
|
.active()
|
||||||
.then((active) =>
|
.then((active) =>
|
||||||
|
|||||||
@@ -172,16 +172,7 @@ export function Session() {
|
|||||||
const messages = sessionMessages
|
const messages = sessionMessages
|
||||||
const descendantSessionIDs = createMemo(() => {
|
const descendantSessionIDs = createMemo(() => {
|
||||||
if (session()?.parentID) return []
|
if (session()?.parentID) return []
|
||||||
const sessions = data.session.list()
|
return data.session.family(route.sessionID).filter((id) => id !== route.sessionID)
|
||||||
const childrenByParent = sessions.reduce((acc, item) => {
|
|
||||||
if (!item.parentID) return acc
|
|
||||||
acc.set(item.parentID, [...(acc.get(item.parentID) ?? []), item.id])
|
|
||||||
return acc
|
|
||||||
}, new Map<string, string[]>())
|
|
||||||
function collect(sessionID: string): string[] {
|
|
||||||
return (childrenByParent.get(sessionID) ?? []).flatMap((id) => [id, ...collect(id)])
|
|
||||||
}
|
|
||||||
return collect(route.sessionID)
|
|
||||||
})
|
})
|
||||||
const permissions = createMemo(() => {
|
const permissions = createMemo(() => {
|
||||||
if (session()?.parentID) return []
|
if (session()?.parentID) return []
|
||||||
|
|||||||
@@ -915,3 +915,122 @@ test("projects live context updates with their message ID", async () => {
|
|||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function sessionInfo(id: string, parentID: string | undefined) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
parentID,
|
||||||
|
projectID: "proj_test",
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 0, updated: 0 },
|
||||||
|
title: id,
|
||||||
|
location: { directory },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mounts a DataProvider whose `/api/session/:id` responses are driven by the
|
||||||
|
// given parent map (sessionID -> parentID). Roots omit the entry. Reused across
|
||||||
|
// the family-index tests below.
|
||||||
|
async function mountData(parents: Record<string, string>) {
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
|
||||||
|
if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]]) })
|
||||||
|
})
|
||||||
|
let data!: ReturnType<typeof useData>
|
||||||
|
let ready!: () => void
|
||||||
|
const mounted = new Promise<void>((resolve) => {
|
||||||
|
ready = resolve
|
||||||
|
})
|
||||||
|
function Probe() {
|
||||||
|
data = useData()
|
||||||
|
onMount(ready)
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
await mounted
|
||||||
|
return { data, app }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("groups an orphan child under its missing parent until the root arrives", async () => {
|
||||||
|
const { data, app } = await mountData({ child: "root" })
|
||||||
|
try {
|
||||||
|
await data.session.refresh("child")
|
||||||
|
// Parent info is absent, so the missing parent is the furthest-known ancestor.
|
||||||
|
expect(data.session.root("child")).toBe("root")
|
||||||
|
expect(data.session.family("child")).toEqual(["child"])
|
||||||
|
expect(data.session.family("root")).toEqual(["child"])
|
||||||
|
|
||||||
|
await data.session.refresh("root")
|
||||||
|
expect(data.session.root("root")).toBe("root")
|
||||||
|
// The tentative root entry folds into the now-known root's family.
|
||||||
|
expect(data.session.family("child")).toEqual(["child", "root"])
|
||||||
|
expect(data.session.family("root")).toEqual(["child", "root"])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("indexes arbitrarily deep nesting under a single root", async () => {
|
||||||
|
const { data, app } = await mountData({ grandchild: "child", child: "root" })
|
||||||
|
try {
|
||||||
|
await data.session.refresh("grandchild")
|
||||||
|
expect(data.session.root("grandchild")).toBe("child")
|
||||||
|
expect(data.session.family("grandchild")).toEqual(["grandchild"])
|
||||||
|
|
||||||
|
await data.session.refresh("child")
|
||||||
|
// grandchild's tentative family (keyed by the missing "child") merges up
|
||||||
|
// toward the still-missing "root".
|
||||||
|
expect(data.session.root("child")).toBe("root")
|
||||||
|
expect(data.session.family("grandchild")).toEqual(["grandchild", "child"])
|
||||||
|
|
||||||
|
await data.session.refresh("root")
|
||||||
|
expect(data.session.root("grandchild")).toBe("root")
|
||||||
|
expect(data.session.root("child")).toBe("root")
|
||||||
|
expect(data.session.family("root")).toEqual(["grandchild", "child", "root"])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("re-registering an existing session is idempotent", async () => {
|
||||||
|
const { data, app } = await mountData({ grandchild: "child", child: "root" })
|
||||||
|
try {
|
||||||
|
await data.session.refresh("grandchild")
|
||||||
|
await data.session.refresh("child")
|
||||||
|
await data.session.refresh("root")
|
||||||
|
const before = data.session.family("root")
|
||||||
|
expect(before).toEqual(["grandchild", "child", "root"])
|
||||||
|
|
||||||
|
await data.session.refresh("child")
|
||||||
|
await data.session.refresh("root")
|
||||||
|
await data.session.refresh("grandchild")
|
||||||
|
expect(data.session.family("root")).toEqual(before)
|
||||||
|
expect(data.session.family("root")).toHaveLength(3)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stops at the last non-repeating ancestor on a parent cycle", async () => {
|
||||||
|
const { data, app } = await mountData({ x: "y", y: "x" })
|
||||||
|
try {
|
||||||
|
await data.session.refresh("x")
|
||||||
|
await data.session.refresh("y")
|
||||||
|
// Does not hang; walking up from "y" stops before re-entering "x".
|
||||||
|
expect(data.session.root("y")).toBe("x")
|
||||||
|
expect(data.session.family("y")).toEqual(["x", "y"])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user