From 124de9ce81228868b32af2da4217d32a39faa1b8 Mon Sep 17 00:00:00 2001 From: roomhackerfrom100 Date: Mon, 6 Jul 2026 16:06:59 +0300 Subject: [PATCH] fix(app): normalize list records in desktop renderer Normalize SDK and persisted list values before array operations so desktop startup can tolerate record-shaped session, command, project, permission, and question payloads.\n\nFixes #35551 --- packages/app/src/components/prompt-input.tsx | 3 ++- .../app/src/components/prompt-input/submit.ts | 5 +++-- packages/app/src/context/directory-sync.ts | 5 +++-- .../app/src/context/global-sync/bootstrap.ts | 18 +++++++++++------- packages/app/src/context/server-sync.tsx | 12 +++++++----- packages/app/src/pages/layout.tsx | 3 ++- packages/app/src/pages/layout/helpers.test.ts | 17 +++++++++++++++++ packages/app/src/pages/layout/helpers.ts | 8 ++++++-- packages/app/src/utils/list-value.test.ts | 18 ++++++++++++++++++ packages/app/src/utils/list-value.ts | 5 +++++ 10 files changed, 74 insertions(+), 20 deletions(-) create mode 100644 packages/app/src/utils/list-value.test.ts create mode 100644 packages/app/src/utils/list-value.ts diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 75ec3b7be52b..eec6371627e1 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -71,6 +71,7 @@ import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" +import { listValue } from "@/utils/list-value" export type PromptInputState = ReturnType @@ -786,7 +787,7 @@ export const PromptInput: Component = (props) => { type: "builtin" as const, })) - const custom = sync().data.command.map((cmd) => ({ + const custom = listValue(sync().data.command).map((cmd) => ({ id: `custom.${cmd.name}`, trigger: cmd.name, title: cmd.name, diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 05d978a947ce..220fa3e62762 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -20,6 +20,7 @@ import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" import { ScopedKey } from "@/utils/server-scope" import { createPromptSubmissionState } from "./submission-state" +import { listValue } from "@/utils/list-value" type PendingPrompt = { abort: AbortController @@ -73,7 +74,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) { const [head, ...tail] = text.split(" ") const cmd = head?.startsWith("/") ? head.slice(1) : undefined - if (cmd && input.sync.data.command.find((item) => item.name === cmd)) { + if (cmd && listValue(input.sync.data.command).find((item) => item.name === cmd)) { setBusy() try { if (!(await wait())) { @@ -460,7 +461,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") const commandName = cmdName.slice(1) - const customCommand = sync().data.command.find((c) => c.name === commandName) + const customCommand = listValue(sync().data.command).find((c) => c.name === commandName) if (customCommand) { clearInput() client.session diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index 68e6b19cef14..10d2f9d814e1 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -5,6 +5,7 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import type { createServerSdkContext } from "./server-sdk" import type { createServerSyncContextInner } from "./server-sync" import type { State } from "./global-sync/types" +import { listValue } from "@/utils/list-value" const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const sessionFields = new Set([ @@ -124,14 +125,14 @@ export const createDirSyncContext = ( const [store, setStore] = current() setStore("limit", (value) => value + count) const response = await client.session.list() - const sessions = (response.data ?? []) + const sessions = listValue(response.data) .filter((session) => !!session?.id) .sort((a, b) => cmp(a.id, b.id)) .slice(0, store.limit) sessions.forEach(serverSync.session.remember) setStore("session", reconcile(sessions, { key: "id" })) }, - more: createMemo(() => current()[0].session.length >= current()[0].limit), + more: createMemo(() => listValue(current()[0].session).length >= current()[0].limit), archive: async (sessionID: string) => { await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } }) current()[1]( diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index c63b702522f5..2a677f3dc10f 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -22,6 +22,7 @@ import { QueryClient, queryOptions } from "@tanstack/solid-query" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { ScopedKey, type ServerScope } from "@/utils/server-scope" +import { listValue } from "@/utils/list-value" type GlobalStore = { ready: boolean @@ -94,7 +95,7 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) => queryFn: () => retry(() => sdk.project.list().then((x) => { - return (x.data ?? []) + return listValue(x.data) .filter((p) => !!p?.id) .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) .slice() @@ -164,7 +165,7 @@ function warmSessions(input: { setStore: SetStoreFunction sdk: OpencodeClient }) { - const known = new Set(input.store.session.map((item) => item.id)) + const known = new Set(listValue(input.store.session).map((item) => item.id)) const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id)) if (ids.length === 0) return Promise.resolve() return Promise.all( @@ -288,14 +289,16 @@ export async function bootstrapDirectory(input: { if (next) input.vcsCache.setStore("value", next) }), ), - input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))), + input.mcp && + (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", listValue(x.data))))), () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)), () => retry(() => input.sdk.permission.list().then((x) => { - const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id) + const permissions = listValue(x.data) + const ids = permissions.map((perm) => perm?.sessionID).filter((id): id is string => !!id) const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + permissions.filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), ) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) @@ -324,8 +327,9 @@ export async function bootstrapDirectory(input: { () => retry(() => input.sdk.question.list().then((x) => { - const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + const questions = listValue(x.data) + const ids = questions.map((question) => question?.sessionID).filter((id): id is string => !!id) + const grouped = groupBySession(questions.filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 9b337aa07655..4a0ffba46c4a 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -46,6 +46,7 @@ import type { ServerScope } from "@/utils/server-scope" import { persisted } from "@/utils/persist" import { toggleMcp } from "./global-sync/mcp" import { createServerSession } from "./server-session" +import { listValue } from "@/utils/list-value" type GlobalStore = { ready: boolean @@ -225,7 +226,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { void retry(() => sdkFor(directory) .command.list() - .then((x) => setStore("command", x.data ?? [])), + .then((x) => setStore("command", listValue(x.data))), ).catch((err) => { showToast({ variant: "error", @@ -261,11 +262,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const meta = sessionMeta.get(key) const retainedLimit = Math.max(store.limit, options?.limit ?? 0, meta?.limit ?? 0) if (meta && meta.limit >= retainedLimit) { - const next = trimSessions(store.session, { + const sessions = listValue(store.session) + const next = trimSessions(sessions, { limit: retainedLimit, permission: session.data.permission, }) - if (next.length !== store.session.length) { + if (next.length !== sessions.length) { setStore("session", reconcile(next, { key: "id" })) } children.unpin(key) @@ -283,12 +285,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { list: (query) => serverSDK.client.session.list(query), }) .then((x) => { - const nonArchived = (x.data ?? []) + const nonArchived = listValue(x.data) .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0) - const childSessions = store.session.filter((s) => !!s.parentID) + const childSessions = listValue(store.session).filter((s) => !!s.parentID) const next = trimSessions([...nonArchived, ...childSessions], { limit, permission: session.data.permission, diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index fd9d16b90ad6..cfcf4c108aba 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -82,6 +82,7 @@ import { } from "./layout/sidebar-workspace" import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { SidebarContent } from "./layout/sidebar-shell" +import { listValue } from "@/utils/list-value" export default function LegacyLayout(props: ParentProps) { const serverSDK = useServerSDK() @@ -488,7 +489,7 @@ export default function LegacyLayout(props: ParentProps) { const sessionKey = `${currentDir()}:${currentSession}` dismissSessionAlert(sessionKey) const [store] = serverSync().child(currentDir(), { bootstrap: false }) - const childSessions = store.session.filter((s) => s.parentID === currentSession) + const childSessions = listValue(store.session).filter((s) => s.parentID === currentSession) for (const child of childSessions) { dismissSessionAlert(`${currentDir()}:${child.id}`) } diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index df87ddfecd2a..1d1210922ce2 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -153,6 +153,23 @@ describe("layout workspace helpers", () => { expect(result?.id).toBe("workspace") }) + test("handles persisted session maps when finding root sessions", () => { + const root = session({ id: "ses_1", directory: "/workspace" }) + const other = session({ id: "ses_2", directory: "/other" }) + + const result = latestRootSession( + [ + { + path: { directory: "/workspace" }, + session: { [root.id]: root, [other.id]: other }, + }, + ], + 120_000, + ) + + expect(result?.id).toBe(root.id) + }) + test("detects project permissions with a filter", () => { const result = hasProjectPermissions( { diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index ce793e282bde..3fdba63e6190 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,11 +1,12 @@ import { getFilename } from "@opencode-ai/core/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" import { pathKey } from "@/utils/path-key" +import { listValue } from "@/utils/list-value" import type { ServerConnection } from "@/context/server" import type { HomeProjectSelection } from "@/context/layout" type SessionStore = { - session?: Session[] + session?: Session[] | Record path: { directory: string } } @@ -26,8 +27,11 @@ function sortSessions(now: number) { const isRootVisibleSession = (session: Session, directory: string) => pathKey(session.directory) === pathKey(directory) && !session.parentID && !session.time?.archived +const sessionList = (session: SessionStore["session"]): Session[] => + listValue(session).filter((item): item is Session => !!item?.id) + export const roots = (store: SessionStore) => - (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) + sessionList(store.session).filter((session) => isRootVisibleSession(session, store.path.directory)) export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) diff --git a/packages/app/src/utils/list-value.test.ts b/packages/app/src/utils/list-value.test.ts new file mode 100644 index 000000000000..4919975dc75a --- /dev/null +++ b/packages/app/src/utils/list-value.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test" +import { listValue } from "./list-value" + +describe("listValue", () => { + test("returns arrays unchanged", () => { + const input = [{ id: "a" }, { id: "b" }] + expect(listValue(input)).toBe(input) + }) + + test("normalizes object maps to arrays", () => { + expect(listValue({ a: { id: "a" }, b: { id: "b" } })).toEqual([{ id: "a" }, { id: "b" }]) + }) + + test("returns an empty array for missing values", () => { + expect(listValue(undefined)).toEqual([]) + expect(listValue(null)).toEqual([]) + }) +}) diff --git a/packages/app/src/utils/list-value.ts b/packages/app/src/utils/list-value.ts new file mode 100644 index 000000000000..829630e79e50 --- /dev/null +++ b/packages/app/src/utils/list-value.ts @@ -0,0 +1,5 @@ +export function listValue(value: T[] | Record | null | undefined): T[] { + if (Array.isArray(value)) return value + if (value && typeof value === "object") return Object.values(value) + return [] +}