diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index bb510c62f1..45cdb611f4 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -52,7 +52,10 @@ export const createDirSyncContext = ( setStore("session", result.index, reconcile(session)) return } - setStore("session", produce((draft) => void draft.splice(result.index, 0, session))) + setStore( + "session", + produce((draft) => void draft.splice(result.index, 0, session)), + ) } return { @@ -127,10 +130,13 @@ export const createDirSyncContext = ( more: createMemo(() => current()[0].session.length >= current()[0].limit), archive: async (sessionID: string) => { await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } }) - current()[1]("session", produce((draft) => { - const match = Binary.search(draft, sessionID, (session) => session.id) - if (match.found) draft.splice(match.index, 1) - })) + current()[1]( + "session", + produce((draft) => { + const match = Binary.search(draft, sessionID, (session) => session.id) + if (match.found) draft.splice(match.index, 1) + }), + ) }, }, mcp: { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 7055e6553b..5da05d19cf 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -241,13 +241,18 @@ export async function bootstrapDirectory(input: { input.sdk.session.status().then(async (x) => { if (input.session) { const statuses = x.data ?? {} - await Promise.all(Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined))) - input.session.set("session_status", produce((draft) => { - for (const sessionID of Object.keys(draft)) { - if (statuses[sessionID]) continue - if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID] - } - })) + await Promise.all( + Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)), + ) + input.session.set( + "session_status", + produce((draft) => { + for (const sessionID of Object.keys(draft)) { + if (statuses[sessionID]) continue + if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID] + } + }), + ) for (const [sessionID, status] of Object.entries(statuses)) { input.session.set("session_status", sessionID, reconcile(status)) } diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 555aa8e9f0..8986b63cdd 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -69,12 +69,28 @@ describe("server session", () => { ctx.store.pin("active") ctx.store.optimistic.add({ sessionID: "active", - message: { id: "message", sessionID: "active", role: "assistant", time: { created: 1 }, parentID: "parent", modelID: "model", providerID: "provider", mode: "build", agent: "agent", path: { cwd: "/repo", root: "/repo" }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, + message: { + id: "message", + sessionID: "active", + role: "assistant", + time: { created: 1 }, + parentID: "parent", + modelID: "model", + providerID: "provider", + mode: "build", + agent: "agent", + path: { cwd: "/repo", root: "/repo" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, parts: [], }) for (let index = 0; index < 50; index++) { - ctx.store.apply({ type: "session.status", properties: { sessionID: `session-${index}`, status: { type: "busy" } } }) + ctx.store.apply({ + type: "session.status", + properties: { sessionID: `session-${index}`, status: { type: "busy" } }, + }) } expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"]) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 5566b9e7b1..5cdcaac615 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -115,9 +115,15 @@ export function createServerSession(client: OpencodeClient) { const preserve = new Set([ ...pinned.keys(), ...requests.keys(), - ...Object.entries(data.permission).filter(([, items]) => items.length > 0).map(([sessionID]) => sessionID), - ...Object.entries(data.question).filter(([, items]) => items.length > 0).map(([sessionID]) => sessionID), - ...Object.entries(data.session_status).filter(([, status]) => status.type !== "idle").map(([sessionID]) => sessionID), + ...Object.entries(data.permission) + .filter(([, items]) => items.length > 0) + .map(([sessionID]) => sessionID), + ...Object.entries(data.question) + .filter(([, items]) => items.length > 0) + .map(([sessionID]) => sessionID), + ...Object.entries(data.session_status) + .filter(([, status]) => status.type !== "idle") + .map(([sessionID]) => sessionID), ]) for (const sessionID of preserve) { let current = data.info[sessionID] @@ -132,7 +138,10 @@ export function createServerSession(client: OpencodeClient) { if (!preserve.has(sessionID)) stale.push(sessionID) } stale.forEach((sessionID) => infoSeen.delete(sessionID)) - setData("info", produce((draft) => stale.forEach((sessionID) => delete draft[sessionID]))) + setData( + "info", + produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])), + ) } return session } @@ -214,27 +223,39 @@ export function createServerSession(client: OpencodeClient) { ) } - const protectedSessions = () => new Set([ - ...pinned.keys(), - ...requests.keys(), - ...inflight.keys(), - ...inflightDiff.keys(), - ...inflightTodo.keys(), - ...optimistic.keys(), - ...Object.entries(data.permission).filter(([, items]) => items.length > 0).map(([sessionID]) => sessionID), - ...Object.entries(data.question).filter(([, items]) => items.length > 0).map(([sessionID]) => sessionID), - ...Object.entries(data.session_status).filter(([, status]) => status.type !== "idle").map(([sessionID]) => sessionID), - ]) + const protectedSessions = () => + new Set([ + ...pinned.keys(), + ...requests.keys(), + ...inflight.keys(), + ...inflightDiff.keys(), + ...inflightTodo.keys(), + ...optimistic.keys(), + ...Object.entries(data.permission) + .filter(([, items]) => items.length > 0) + .map(([sessionID]) => sessionID), + ...Object.entries(data.question) + .filter(([, items]) => items.length > 0) + .map(([sessionID]) => sessionID), + ...Object.entries(data.session_status) + .filter(([, status]) => status.type !== "idle") + .map(([sessionID]) => sessionID), + ]) const touch = (sessionID: string) => - evict(pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() })) + evict( + pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }), + ) const fetchMessages = async (sessionID: string, limit: number, before?: string) => { const response = await retry(() => client.session.messages({ sessionID, limit, before })) const items = (response.data ?? []).filter((item) => !!item?.info?.id) return { session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)), - part: items.map((item) => ({ id: item.info.id, part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) })), + part: items.map((item) => ({ + id: item.info.id, + part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), + })), cursor: response.response.headers.get("x-next-cursor") ?? undefined, complete: !response.response.headers.get("x-next-cursor"), } @@ -284,7 +305,11 @@ export function createServerSession(client: OpencodeClient) { const prefetch = async (sessionID: string, limit: number) => { touch(sessionID) await inflight.get(sessionID) - if (Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 && (meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit)) return + if ( + Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 && + (meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit) + ) + return await runInflight(inflight, sessionID, () => loadMessages(sessionID, limit)) } @@ -292,8 +317,22 @@ export function createServerSession(client: OpencodeClient) { const properties = event.properties if (!properties || typeof properties !== "object") return if ("sessionID" in properties && typeof properties.sessionID === "string") return properties.sessionID - if ("info" in properties && properties.info && typeof properties.info === "object" && "sessionID" in properties.info && typeof properties.info.sessionID === "string") return properties.info.sessionID - if ("part" in properties && properties.part && typeof properties.part === "object" && "sessionID" in properties.part && typeof properties.part.sessionID === "string") return properties.part.sessionID + if ( + "info" in properties && + properties.info && + typeof properties.info === "object" && + "sessionID" in properties.info && + typeof properties.info.sessionID === "string" + ) + return properties.info.sessionID + if ( + "part" in properties && + properties.part && + typeof properties.part === "object" && + "sessionID" in properties.part && + typeof properties.part.sessionID === "string" + ) + return properties.part.sessionID } const apply = (event: { type: string; properties?: unknown }) => { @@ -315,7 +354,10 @@ export function createServerSession(client: OpencodeClient) { case "session.deleted": { const sessionID = (event.properties as { info: Session }).info.id infoSeen.delete(sessionID) - setData("info", produce((draft) => void delete draft[sessionID])) + setData( + "info", + produce((draft) => void delete draft[sessionID]), + ) evict([sessionID]) return } @@ -343,30 +385,36 @@ export function createServerSession(client: OpencodeClient) { } const result = Binary.search(messages, info.id, (message) => message.id) if (result.found) setData("message", info.sessionID, result.index, reconcile(info)) - if (!result.found) setData("message", info.sessionID, (value = []) => { - const next = value.slice() - next.splice(result.index, 0, info) - return next - }) + if (!result.found) + setData("message", info.sessionID, (value = []) => { + const next = value.slice() + next.splice(result.index, 0, info) + return next + }) return } case "message.removed": { const props = event.properties as { sessionID: string; messageID: string } - setData(produce((draft) => { - const messages = draft.message[props.sessionID] - if (messages) { - const result = Binary.search(messages, props.messageID, (message) => message.id) - if (result.found) messages.splice(result.index, 1) - } - for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id] - delete draft.part[props.messageID] - })) + setData( + produce((draft) => { + const messages = draft.message[props.sessionID] + if (messages) { + const result = Binary.search(messages, props.messageID, (message) => message.id) + if (result.found) messages.splice(result.index, 1) + } + for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id] + delete draft.part[props.messageID] + }), + ) return } case "message.part.updated": { const part = (event.properties as { part: Part }).part if (SKIP_PARTS.has(part.type)) return - setData("part_text_accum_delta", produce((draft) => void delete draft[part.id])) + setData( + "part_text_accum_delta", + produce((draft) => void delete draft[part.id]), + ) const parts = data.part[part.messageID] if (!parts) { setData("part", part.messageID, [part]) @@ -374,23 +422,26 @@ export function createServerSession(client: OpencodeClient) { } const result = Binary.search(parts, part.id, (item) => item.id) if (result.found) setData("part", part.messageID, result.index, reconcile(part)) - if (!result.found) setData("part", part.messageID, (value = []) => { - const next = value.slice() - next.splice(result.index, 0, part) - return next - }) + if (!result.found) + setData("part", part.messageID, (value = []) => { + const next = value.slice() + next.splice(result.index, 0, part) + return next + }) return } case "message.part.removed": { const props = event.properties as { messageID: string; partID: string } - setData(produce((draft) => { - delete draft.part_text_accum_delta[props.partID] - const parts = draft.part[props.messageID] - if (!parts) return - const result = Binary.search(parts, props.partID, (part) => part.id) - if (result.found) parts.splice(result.index, 1) - if (parts.length === 0) delete draft.part[props.messageID] - })) + setData( + produce((draft) => { + delete draft.part_text_accum_delta[props.partID] + const parts = draft.part[props.messageID] + if (!parts) return + const result = Binary.search(parts, props.partID, (part) => part.id) + if (result.found) parts.splice(result.index, 1) + if (parts.length === 0) delete draft.part[props.messageID] + }), + ) return } case "message.part.delta": { @@ -401,13 +452,21 @@ export function createServerSession(client: OpencodeClient) { if (!result.found) return const field = props.field as keyof (typeof parts)[number] const current = parts[result.index]?.[field] - setData("part_text_accum_delta", props.partID, (value) => (value ?? (typeof current === "string" ? current : "")) + props.delta) - setData("part", props.messageID, produce((draft) => { - if (!draft) return - const part = draft[result.index] - const field = props.field as keyof typeof part - ;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta - })) + setData( + "part_text_accum_delta", + props.partID, + (value) => (value ?? (typeof current === "string" ? current : "")) + props.delta, + ) + setData( + "part", + props.messageID, + produce((draft) => { + if (!draft) return + const part = draft[result.index] + const field = props.field as keyof typeof part + ;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta + }), + ) return } case "permission.asked": { @@ -415,16 +474,25 @@ export function createServerSession(client: OpencodeClient) { const permissions = data.permission[permission.sessionID] ?? [] const result = Binary.search(permissions, permission.id, (item) => item.id) if (result.found) setData("permission", permission.sessionID, result.index, reconcile(permission)) - if (!result.found) setData("permission", permission.sessionID, produce((draft = []) => void draft.splice(result.index, 0, permission))) + if (!result.found) + setData( + "permission", + permission.sessionID, + produce((draft = []) => void draft.splice(result.index, 0, permission)), + ) return } case "permission.replied": { const props = event.properties as { sessionID: string; requestID: string } - setData("permission", props.sessionID, produce((draft) => { - if (!draft) return - const result = Binary.search(draft, props.requestID, (item) => item.id) - if (result.found) draft.splice(result.index, 1) - })) + setData( + "permission", + props.sessionID, + produce((draft) => { + if (!draft) return + const result = Binary.search(draft, props.requestID, (item) => item.id) + if (result.found) draft.splice(result.index, 1) + }), + ) return } case "question.asked": { @@ -432,17 +500,26 @@ export function createServerSession(client: OpencodeClient) { const questions = data.question[question.sessionID] ?? [] const result = Binary.search(questions, question.id, (item) => item.id) if (result.found) setData("question", question.sessionID, result.index, reconcile(question)) - if (!result.found) setData("question", question.sessionID, produce((draft = []) => void draft.splice(result.index, 0, question))) + if (!result.found) + setData( + "question", + question.sessionID, + produce((draft = []) => void draft.splice(result.index, 0, question)), + ) return } case "question.replied": case "question.rejected": { const props = event.properties as { sessionID: string; requestID: string } - setData("question", props.sessionID, produce((draft) => { - if (!draft) return - const result = Binary.search(draft, props.requestID, (item) => item.id) - if (result.found) draft.splice(result.index, 1) - })) + setData( + "question", + props.sessionID, + produce((draft) => { + if (!draft) return + const result = Binary.search(draft, props.requestID, (item) => item.id) + if (result.found) draft.splice(result.index, 1) + }), + ) } } } @@ -478,12 +555,19 @@ export function createServerSession(client: OpencodeClient) { if (items) items.set(input.message.id, input) if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, input]])) setData("message", input.sessionID, (messages = []) => merge(messages, [input.message])) - setData("part", input.message.id, input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))) + setData( + "part", + input.message.id, + input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), + ) }, remove(input: { sessionID: string; messageID: string }) { clearOptimistic(input.sessionID, input.messageID) setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID)) - setData("part", produce((draft) => void delete draft[input.messageID])) + setData( + "part", + produce((draft) => void delete draft[input.messageID]), + ) }, }, diff(sessionID: string, options?: { force?: boolean }) { @@ -509,7 +593,11 @@ export function createServerSession(client: OpencodeClient) { }) }, history: { - more: (sessionID: string) => data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined && !meta.complete[sessionID] && !!meta.cursor[sessionID], + more: (sessionID: string) => + data.message[sessionID] !== undefined && + meta.limit[sessionID] !== undefined && + !meta.complete[sessionID] && + !!meta.cursor[sessionID], loading: (sessionID: string) => meta.loading[sessionID] ?? false, async loadMore(sessionID: string, count = historyMessagePageSize) { touch(sessionID) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 0938163535..2d18c4aecc 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -702,7 +702,9 @@ export default function LegacyLayout(props: ParentProps) { } async function prefetchMessages(directory: string, sessionID: string, token: number) { - await serverSync().session.prefetch(sessionID, prefetchChunk).catch(() => {}) + await serverSync() + .session.prefetch(sessionID, prefetchChunk) + .catch(() => {}) if (prefetchToken.value !== token) return for (const stale of markPrefetched(directory, sessionID)) serverSync().session.evict(stale) } diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index fb89cf76e7..9de9b6474e 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -153,16 +153,23 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) const [sessionStore] = serverSync().child(props.session.directory) const hasPermissions = createMemo(() => { - return !!sessionPermissionRequest(sessionStore.session, serverSync().session.data.permission, props.session.id, (item) => { - return !permission.autoResponds(item, props.session.directory) - }) + return !!sessionPermissionRequest( + sessionStore.session, + serverSync().session.data.permission, + props.session.id, + (item) => { + return !permission.autoResponds(item, props.session.directory) + }, + ) }) const isWorking = createMemo(() => { if (hasPermissions()) return false return serverSync().session.data.session_working(props.session.id) }) - const tint = createMemo(() => messageAgentColor(serverSync().session.data.message[props.session.id], sessionStore.agent)) + const tint = createMemo(() => + messageAgentColor(serverSync().session.data.message[props.session.id], sessionStore.agent), + ) const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded())) const currentChild = createMemo(() => { if (!props.showChild) return diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 577de8386d..12960d436d 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -516,9 +516,7 @@ export default function Page() { todoTimer = undefined if (!id) return if (status === "idle" && !blocked) return - const cached = untrack( - () => sync().data.todo[id] !== undefined, - ) + const cached = untrack(() => sync().data.todo[id] !== undefined) todoFrame = requestAnimationFrame(() => { todoFrame = undefined