Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof usePrompt>

Expand Down Expand Up @@ -786,7 +787,7 @@ export const PromptInput: Component<PromptInputProps> = (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,
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/components/prompt-input/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())) {
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/context/directory-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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](
Expand Down
18 changes: 11 additions & 7 deletions packages/app/src/context/global-sync/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -164,7 +165,7 @@ function warmSessions(input: {
setStore: SetStoreFunction<State>
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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 })
Expand Down
12 changes: 7 additions & 5 deletions packages/app/src/context/server-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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}`)
}
Expand Down
17 changes: 17 additions & 0 deletions packages/app/src/pages/layout/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
8 changes: 6 additions & 2 deletions packages/app/src/pages/layout/helpers.ts
Original file line number Diff line number Diff line change
@@ -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<string, Session>
path: { directory: string }
}

Expand All @@ -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))

Expand Down
18 changes: 18 additions & 0 deletions packages/app/src/utils/list-value.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
5 changes: 5 additions & 0 deletions packages/app/src/utils/list-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function listValue<T>(value: T[] | Record<string, T> | null | undefined): T[] {
if (Array.isArray(value)) return value
if (value && typeof value === "object") return Object.values(value)
return []
}
Loading