chore: merge dev into v2 (#35591)
Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Jay <air@live.ca> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
This commit is contained in:
parent
f87998f37f
commit
9e0d3976e1
332 changed files with 24739 additions and 4586 deletions
40
packages/app/src/context/closed-tabs.ts
Normal file
40
packages/app/src/context/closed-tabs.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { SessionTab, Tab } from "./tabs"
|
||||
|
||||
export type ClosedTab = {
|
||||
tab: SessionTab
|
||||
index: number
|
||||
}
|
||||
|
||||
const CLOSED_TAB_LIMIT = 25
|
||||
|
||||
// Only session tabs are recorded; closing a draft tab deletes its persisted
|
||||
// state, so a reopened draft would come back empty anyway.
|
||||
export function pushClosedTab(stack: ClosedTab[], tab: Tab, index: number): ClosedTab[] {
|
||||
if (tab.type !== "session") return stack
|
||||
return [...stack, { tab: { ...tab }, index }].slice(-CLOSED_TAB_LIMIT)
|
||||
}
|
||||
|
||||
// Pops the most recently closed tab that is not open again,
|
||||
// discarding stale entries along the way.
|
||||
export function takeClosedTab(stack: ClosedTab[], tabs: Tab[]): { entry?: ClosedTab; stack: ClosedTab[] } {
|
||||
const remaining = [...stack]
|
||||
while (remaining.length) {
|
||||
const entry = remaining.pop()
|
||||
if (entry && !isOpen(tabs, entry.tab)) return { entry, stack: remaining }
|
||||
}
|
||||
return { stack: remaining }
|
||||
}
|
||||
|
||||
export function removeClosedTabs(stack: ClosedTab[], server: SessionTab["server"], sessionIDs: string[]) {
|
||||
const removed = new Set(sessionIDs)
|
||||
return stack.filter((entry) => entry.tab.server !== server || !removed.has(entry.tab.sessionId))
|
||||
}
|
||||
|
||||
export function nextTabAfterClose(tabs: Tab[], index: number, active: boolean) {
|
||||
if (!active) return undefined
|
||||
return tabs[index + 1] ?? tabs[index - 1] ?? null
|
||||
}
|
||||
|
||||
function isOpen(tabs: Tab[], tab: SessionTab) {
|
||||
return tabs.some((item) => item.type === "session" && item.server === tab.server && item.sessionId === tab.sessionId)
|
||||
}
|
||||
|
|
@ -85,7 +85,15 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
|||
active: null as CommentFocus | null,
|
||||
})
|
||||
|
||||
const all = () => aggregate(store.comments)
|
||||
// Reuse the previous array when contents are unchanged so consumers keep a stable
|
||||
// identity; a fresh array per call cascaded into diff annotation re-renders.
|
||||
let lastAll: LineComment[] = []
|
||||
const all = () => {
|
||||
const next = aggregate(store.comments)
|
||||
if (next.length === lastAll.length && next.every((item, index) => item === lastAll[index])) return lastAll
|
||||
lastAll = next
|
||||
return next
|
||||
}
|
||||
|
||||
const setRef = (
|
||||
key: "focus" | "active",
|
||||
|
|
|
|||
|
|
@ -127,10 +127,14 @@ export function createFileTreeStore(options: TreeStoreOptions) {
|
|||
return promise
|
||||
}
|
||||
|
||||
const expandDir = (input: string) => {
|
||||
// `list: false` marks a directory expanded without fetching its children, for
|
||||
// trees whose nodes are synthesized from a filter; listing directories that
|
||||
// only exist on a diff's base branch fails and surfaces error toasts.
|
||||
const expandDir = (input: string, behavior?: { list?: boolean }) => {
|
||||
const dir = options.normalizeDir(input)
|
||||
ensureDir(dir)
|
||||
setTree("dir", dir, "expanded", true)
|
||||
if (behavior?.list === false) return
|
||||
void listDir(dir)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,50 +1,55 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { createServerSession } from "../server-session"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
|
||||
function directoryState() {
|
||||
return createStore<State>({
|
||||
status: "loading",
|
||||
agent: [],
|
||||
command: [],
|
||||
reference: [],
|
||||
project: "",
|
||||
projectMeta: undefined,
|
||||
icon: undefined,
|
||||
provider_ready: true,
|
||||
provider,
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_working(id: string) {
|
||||
return this.session_status[id]?.type !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: true,
|
||||
mcp: {},
|
||||
mcp_resource: {},
|
||||
lsp_ready: true,
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
}
|
||||
|
||||
describe("bootstrapDirectory", () => {
|
||||
test("marks a loading directory partial during bootstrap and complete after success", async () => {
|
||||
const mcpReads: string[] = []
|
||||
const [store, setStore] = createStore<State>({
|
||||
status: "loading",
|
||||
agent: [],
|
||||
command: [],
|
||||
reference: [],
|
||||
project: "",
|
||||
projectMeta: undefined,
|
||||
icon: undefined,
|
||||
provider_ready: true,
|
||||
provider,
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_working(id: string) {
|
||||
return this.session_status[id]?.type !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: true,
|
||||
mcp: {},
|
||||
mcp_resource: {},
|
||||
lsp_ready: true,
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
const [store, setStore] = directoryState()
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
|
|
@ -93,6 +98,66 @@ describe("bootstrapDirectory", () => {
|
|||
expect(store.status).toBe("complete")
|
||||
expect(mcpReads).toEqual([])
|
||||
})
|
||||
|
||||
test("seeds session status even while warming session info stalls", async () => {
|
||||
const [store, setStore] = directoryState()
|
||||
const stalled = Promise.withResolvers<never>()
|
||||
const client = {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: {
|
||||
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
|
||||
get: () => stalled.promise,
|
||||
},
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: { list: async () => ({ data: [] }) },
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: { status: async () => ({ data: {} }) },
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient
|
||||
const session = createServerSession(client)
|
||||
const stale: Session = {
|
||||
id: "ses_stale",
|
||||
slug: "ses_stale",
|
||||
projectID: "project",
|
||||
directory: "/project",
|
||||
title: "stale",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
session.remember(stale)
|
||||
session.set("session_status", stale.id, { type: "busy" })
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
scope: ServerScope.local,
|
||||
mcp: false,
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: client,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
loadSessions() {},
|
||||
translate: (key) => key,
|
||||
queryClient: new QueryClient(),
|
||||
session,
|
||||
})
|
||||
|
||||
const deadline = Date.now() + 500
|
||||
while (!session.data.session_working("ses_busy") && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
|
||||
expect(session.data.session_status["ses_busy"]?.type).toBe("busy")
|
||||
expect(session.data.session_status[stale.id]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("query keys", () => {
|
||||
|
|
|
|||
|
|
@ -247,25 +247,29 @@ export async function bootstrapDirectory(input: {
|
|||
() =>
|
||||
retry(() =>
|
||||
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]
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
input.session.set("session_status", sessionID, reconcile(status))
|
||||
}
|
||||
if (!input.session) {
|
||||
input.setStore("session_status", x.data!)
|
||||
return
|
||||
}
|
||||
if (!input.session) input.setStore("session_status", x.data!)
|
||||
const statuses = x.data ?? {}
|
||||
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))
|
||||
}
|
||||
// Warm session info only after seeding statuses so a stalled session
|
||||
// fetch cannot park busy indicators behind it, mirroring how live
|
||||
// session.status events apply first and resolve info in the background.
|
||||
await Promise.all(
|
||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
!seededProject &&
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createEffect, createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createServerProjects, ServerConnection, useServer } from "./server"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useServerHealth } from "@/utils/server-health"
|
||||
import { createServerSdkContext } from "./server-sdk"
|
||||
import { createServerSyncContext } from "./server-sync"
|
||||
|
|
@ -127,6 +128,14 @@ function createServerCtx(
|
|||
}
|
||||
|
||||
const projectsList = createMemo(() => projects.list().map(enrich))
|
||||
const recentlyClosedList = createMemo(() => {
|
||||
const known = new Set(sync.data.project.map((project) => pathKey(project.worktree)))
|
||||
return projects
|
||||
.recentlyClosed()
|
||||
.filter((worktree) => known.has(pathKey(worktree)))
|
||||
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
|
||||
.map((worktree) => enrich({ worktree, expanded: false }))
|
||||
})
|
||||
|
||||
const isLocal =
|
||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||
|
|
@ -139,6 +148,7 @@ function createServerCtx(
|
|||
projects: {
|
||||
...projects,
|
||||
list: projectsList,
|
||||
recentlyClosed: recentlyClosedList,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { same } from "@/utils/same"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
|
|
@ -493,7 +494,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
const root = rootFor(project.worktree)
|
||||
if (root === project.worktree) continue
|
||||
|
||||
server.projects.close(project.worktree)
|
||||
server.projects.remove(project.worktree)
|
||||
|
||||
if (!seen.has(root)) {
|
||||
server.projects.open(root)
|
||||
|
|
@ -613,6 +614,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
},
|
||||
projects: {
|
||||
list,
|
||||
recentlyClosed: createMemo(() => {
|
||||
const known = new Set(serverSync().data.project.map((project) => pathKey(project.worktree)))
|
||||
return server.projects
|
||||
.recentlyClosed()
|
||||
.filter((worktree) => known.has(pathKey(worktree)))
|
||||
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
|
||||
.map((worktree) => enrich({ worktree, expanded: false }))
|
||||
}),
|
||||
open(directory: string) {
|
||||
const root = rootFor(directory)
|
||||
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||
|
|
|
|||
|
|
@ -15,11 +15,13 @@ const session = (id: string, parentID?: string): Session => ({
|
|||
})
|
||||
|
||||
type UserMessage = Extract<Message, { role: "user" }>
|
||||
type AssistantMessage = Extract<Message, { role: "assistant" }>
|
||||
type TextPart = Extract<Part, { type: "text" }>
|
||||
type MessageResponse = {
|
||||
data: { info: Message; parts: Part[] }[]
|
||||
response: { headers: Headers }
|
||||
}
|
||||
type SingleMessageResponse = { data: MessageResponse["data"][number] }
|
||||
|
||||
const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
|
||||
id,
|
||||
|
|
@ -31,6 +33,22 @@ const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage
|
|||
...input,
|
||||
})
|
||||
|
||||
const assistantMessage = (id: string, parentID: string, input: Partial<AssistantMessage> = {}): AssistantMessage => ({
|
||||
id,
|
||||
sessionID: "child",
|
||||
role: "assistant",
|
||||
time: { created: Number(id.at(-1)), completed: Number(id.at(-1)) },
|
||||
parentID,
|
||||
modelID: "model",
|
||||
providerID: "provider",
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: "/repo", root: "/repo" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
...input,
|
||||
})
|
||||
|
||||
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
|
||||
id: "part",
|
||||
sessionID: "child",
|
||||
|
|
@ -45,6 +63,8 @@ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageR
|
|||
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
|
||||
})
|
||||
|
||||
const singleResponse = (info: Message, parts: Part[] = []): SingleMessageResponse => ({ data: { info, parts } })
|
||||
|
||||
const deferredResponse = () => Promise.withResolvers<MessageResponse>()
|
||||
|
||||
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
|
||||
|
|
@ -71,6 +91,40 @@ function messageClient(...responses: Array<MessageResponse | Promise<MessageResp
|
|||
})
|
||||
}
|
||||
|
||||
function rootMessageClient(
|
||||
pages: Array<MessageResponse | Promise<MessageResponse>>,
|
||||
roots: Array<SingleMessageResponse | Promise<SingleMessageResponse>>,
|
||||
) {
|
||||
let pageIndex = 0
|
||||
let rootIndex = 0
|
||||
const requests: unknown[] = []
|
||||
const rootRequests: unknown[] = []
|
||||
const rootWaiting = new Map<number, () => void>()
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: session("child", "root") }),
|
||||
messages: (input: unknown) => {
|
||||
requests.push(input)
|
||||
return pages[pageIndex++]
|
||||
},
|
||||
message: (input: unknown) => {
|
||||
rootRequests.push(input)
|
||||
rootWaiting.get(rootRequests.length)?.()
|
||||
rootWaiting.delete(rootRequests.length)
|
||||
return roots[rootIndex++]
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
return Object.assign(client, {
|
||||
requests,
|
||||
rootRequests,
|
||||
rootRequested(count: number) {
|
||||
if (rootRequests.length >= count) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => rootWaiting.set(count, resolve))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const retryImmediately: typeof retry = async (task, options = {}) => {
|
||||
const attempts = options.attempts ?? 3
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
|
|
@ -124,6 +178,238 @@ describe("server session", () => {
|
|||
expect(ctx.store.data.message.root).toEqual([])
|
||||
})
|
||||
|
||||
test("backfills an assistant-only initial page through its user root", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response(
|
||||
assistants.map((info) => ({ info, parts: [] })),
|
||||
"older",
|
||||
),
|
||||
],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
expect(store.history.more("child")).toBe(true)
|
||||
})
|
||||
|
||||
test("does not let an optimistic user suppress initial root backfill", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const part = textPart(user.id)
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response(
|
||||
assistants.map((info) => ({ info, parts: [] })),
|
||||
"older",
|
||||
),
|
||||
],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
|
||||
|
||||
await store.sync("child")
|
||||
store.optimistic.remove({ sessionID: "child", messageID: user.id })
|
||||
|
||||
expect(client.requests).toHaveLength(1)
|
||||
expect(client.rootRequests).toHaveLength(1)
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
})
|
||||
|
||||
test("backfills the parent of fetched assistants when another user is cached", async () => {
|
||||
const unrelated = userMessage("message-0", { time: { created: 0 } })
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response([{ info: unrelated, parts: [] }]),
|
||||
response(
|
||||
assistants.map((info) => ({ info, parts: [] })),
|
||||
"older",
|
||||
),
|
||||
],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.requests).toHaveLength(2)
|
||||
expect(client.rootRequests).toHaveLength(1)
|
||||
expect(store.data.message.child).toEqual([unrelated, user, ...assistants])
|
||||
})
|
||||
|
||||
test("preserves cached history between an injected parent and the page boundary", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const cached = userMessage("message-3", { time: { created: 3 } })
|
||||
const assistant = assistantMessage("message-4", user.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([user, cached, assistant])
|
||||
})
|
||||
|
||||
test("refreshes a cached parent omitted by an assistant-only replacement page", async () => {
|
||||
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
|
||||
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
|
||||
const stalePart = textPart(stale.id, { text: "stale" })
|
||||
const freshPart = { ...stalePart, text: "fresh" }
|
||||
const assistant = assistantMessage("message-2", stale.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||
[singleResponse(fresh, [freshPart])],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||
expect(store.data.part[stale.id]).toEqual([freshPart])
|
||||
})
|
||||
|
||||
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
|
||||
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
|
||||
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
|
||||
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
|
||||
const refreshed = { ...confirmed, text: "fresh" }
|
||||
const pending = textPart(stale.id, { id: "pending", text: "pending" })
|
||||
const assistant = assistantMessage("message-2", stale.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||
[singleResponse(fresh, [refreshed])],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
|
||||
})
|
||||
|
||||
test("uses a parent received by SSE during the replacement load", async () => {
|
||||
const pending = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const client = rootMessageClient([pending.promise], [])
|
||||
const store = createServerSession(client)
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: user } })
|
||||
pending.resolve(response([{ info: assistant, parts: [] }], "older"))
|
||||
await loading
|
||||
|
||||
expect(client.rootRequests).toEqual([])
|
||||
expect(store.data.message.child).toEqual([user, assistant])
|
||||
})
|
||||
|
||||
test("uses a successful retry over events received by a failed backfill attempt", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const live = { ...user, agent: "stale" }
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response(
|
||||
assistants.map((info) => ({ info, parts: [] })),
|
||||
"older",
|
||||
),
|
||||
],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(client.requests).toHaveLength(1)
|
||||
expect(client.rootRequests).toHaveLength(2)
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
})
|
||||
|
||||
test("preserves newer-page events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const live = { ...assistant, cost: 1 }
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [] }], "older")],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([user, live])
|
||||
})
|
||||
|
||||
test("preserves unrelated message events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const live = userMessage("message-4", { time: { created: 4 } })
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [] }], "older")],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([user, assistant, live])
|
||||
})
|
||||
|
||||
test("preserves newer-page part events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const stale = textPart(assistant.id, { text: "stale" })
|
||||
const live = { ...stale, text: "live" }
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [stale] }], "older")],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[assistant.id]).toEqual([live])
|
||||
})
|
||||
|
||||
test("merges live events into the initial page", async () => {
|
||||
const pending = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
|
|
@ -905,6 +1191,26 @@ describe("server session", () => {
|
|||
expect(store.data.message.child).toEqual([latest])
|
||||
})
|
||||
|
||||
test("does not scan cached messages for user roots during history prepend", async () => {
|
||||
const guard = { active: false }
|
||||
const latest = new Proxy(userMessage("message-2", { time: { created: 2 } }), {
|
||||
get(target, property, receiver) {
|
||||
if (guard.active && property === "role") throw new Error("cached role accessed")
|
||||
return Reflect.get(target, property, receiver)
|
||||
},
|
||||
})
|
||||
const older = userMessage("message-1")
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: latest, parts: [] }], "older"), response([{ info: older, parts: [] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
guard.active = true
|
||||
|
||||
await store.history.loadMore("child")
|
||||
|
||||
expect(store.data.message.child).toEqual([older, latest])
|
||||
})
|
||||
|
||||
test("preserves loaded history during an incomplete refresh", async () => {
|
||||
const older = userMessage("message-1")
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type {
|
|||
import { batch } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
|
||||
import { sessionNotFoundError } from "@/utils/server-errors"
|
||||
import { rootSession } from "@/utils/session-route"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||
|
||||
|
|
@ -53,6 +54,11 @@ type MessageLoadState = {
|
|||
clearedMessageParts: Set<string>
|
||||
}
|
||||
|
||||
type MessageLoadBaseline = Pick<
|
||||
MessageLoadState,
|
||||
"touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts"
|
||||
>
|
||||
|
||||
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
||||
const session = [...page.session]
|
||||
|
|
@ -235,7 +241,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
if (pending) return pending
|
||||
const active = generation(sessionID)
|
||||
const request = client.session.get({ sessionID }).then((result) => {
|
||||
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
|
||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||
if (generations.get(sessionID) !== active) return result.data
|
||||
return remember(result.data)
|
||||
})
|
||||
|
|
@ -346,7 +352,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
load.touchedParts.set(messageID, new Set([partID]))
|
||||
}
|
||||
|
||||
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
|
||||
const resetMessageLoad = (sessionID: string, load: MessageLoadState, baseline?: MessageLoadBaseline) => {
|
||||
load.touchedMessages.clear()
|
||||
load.retainedMessages.clear()
|
||||
load.touchedParts.clear()
|
||||
|
|
@ -379,8 +385,27 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
parts.forEach((partID) => touched.add(partID))
|
||||
load.touchedParts.set(messageID, touched)
|
||||
}
|
||||
baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID))
|
||||
baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID))
|
||||
baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID))
|
||||
baseline?.touchedParts.forEach((parts, messageID) => {
|
||||
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||
parts.forEach((partID) => touched.add(partID))
|
||||
load.touchedParts.set(messageID, touched)
|
||||
})
|
||||
}
|
||||
|
||||
const messageLoadBaseline = (load: MessageLoadState, exclude: string): MessageLoadBaseline => ({
|
||||
touchedMessages: new Set([...load.touchedMessages].filter((messageID) => messageID !== exclude)),
|
||||
retainedMessages: new Set([...load.retainedMessages].filter((messageID) => messageID !== exclude)),
|
||||
touchedParts: new Map(
|
||||
[...load.touchedParts]
|
||||
.filter(([messageID]) => messageID !== exclude)
|
||||
.map(([messageID, parts]) => [messageID, new Set(parts)]),
|
||||
),
|
||||
clearedMessageParts: new Set([...load.clearedMessageParts].filter((messageID) => messageID !== exclude)),
|
||||
})
|
||||
|
||||
const evict = (sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
const evicted = new Set(sessionIDs)
|
||||
|
|
@ -459,6 +484,18 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
}
|
||||
}
|
||||
|
||||
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return client.session.message({ sessionID, messageID })
|
||||
})
|
||||
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
|
||||
return {
|
||||
message: cleanMessage(response.data.info),
|
||||
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
}
|
||||
}
|
||||
|
||||
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
||||
const messageIDs = new Set(messages.map((message) => message.id))
|
||||
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
|
||||
|
|
@ -577,36 +614,79 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
messageLoads.set(sessionID, load)
|
||||
setMeta("loading", sessionID, true)
|
||||
let applied = false
|
||||
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||
.then((page) => {
|
||||
if (generations.get(sessionID) !== active) return
|
||||
const first = page.session.reduce<Message | undefined>(
|
||||
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
||||
undefined,
|
||||
)
|
||||
const preserveUnfetched =
|
||||
mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
||||
applyMessagePage(
|
||||
sessionID,
|
||||
page,
|
||||
messageLoads.get(sessionID) === load ? load : undefined,
|
||||
preserveUnfetched,
|
||||
mode !== "prepend",
|
||||
)
|
||||
applied = true
|
||||
})
|
||||
.finally(() => {
|
||||
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
|
||||
for (const messageID of load.orphanParents) {
|
||||
if (!orphanParts.get(sessionID)?.has(messageID)) continue
|
||||
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||
orphanParts.get(sessionID)?.delete(messageID)
|
||||
}
|
||||
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
|
||||
try {
|
||||
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||
const first = page.session.reduce<Message | undefined>(
|
||||
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
||||
undefined,
|
||||
)
|
||||
if (generations.get(sessionID) !== active) return
|
||||
|
||||
const parents = [] as Awaited<ReturnType<typeof fetchMessage>>[]
|
||||
if (mode !== "prepend") {
|
||||
const users = new Set([
|
||||
...page.session.filter((message) => message.role === "user").map((message) => message.id),
|
||||
...(data.message[sessionID] ?? [])
|
||||
.filter((message) => {
|
||||
if (message.role !== "user") return false
|
||||
const item = optimistic.get(sessionID)?.get(message.id)
|
||||
return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true)
|
||||
})
|
||||
.map((message) => message.id),
|
||||
])
|
||||
const parentIDs = [
|
||||
...new Set(
|
||||
page.session.flatMap((message) =>
|
||||
message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [],
|
||||
),
|
||||
),
|
||||
]
|
||||
for (const parentID of parentIDs) {
|
||||
if (generations.get(sessionID) !== active) break
|
||||
const parent = await fetchMessage(sessionID, parentID, () =>
|
||||
resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)),
|
||||
)
|
||||
if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`)
|
||||
parents.push(parent)
|
||||
}
|
||||
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
|
||||
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
|
||||
})
|
||||
}
|
||||
if (generations.get(sessionID) !== active) return
|
||||
const result =
|
||||
mode === "prepend"
|
||||
? page
|
||||
: {
|
||||
...page,
|
||||
session: merge(
|
||||
page.session,
|
||||
parents.map((parent) => parent.message),
|
||||
),
|
||||
part: merge(
|
||||
page.part,
|
||||
parents.map((parent) => ({ id: parent.message.id, part: parent.parts })),
|
||||
),
|
||||
}
|
||||
const preserveUnfetched =
|
||||
mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
||||
applyMessagePage(
|
||||
sessionID,
|
||||
result,
|
||||
messageLoads.get(sessionID) === load ? load : undefined,
|
||||
preserveUnfetched,
|
||||
mode !== "prepend",
|
||||
)
|
||||
applied = true
|
||||
} finally {
|
||||
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
|
||||
for (const messageID of load.orphanParents) {
|
||||
if (!orphanParts.get(sessionID)?.has(messageID)) continue
|
||||
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||
orphanParts.get(sessionID)?.delete(messageID)
|
||||
}
|
||||
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
|
||||
}
|
||||
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
|
||||
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
|
||||
}
|
||||
}
|
||||
|
||||
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ describe("createServerProjects", () => {
|
|||
test("keeps active and explicit server buckets in one reactive store", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {} })
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const active = createServerProjects({ scope, store, setStore })
|
||||
const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
|
||||
|
||||
|
|
@ -115,6 +115,88 @@ describe("createServerProjects", () => {
|
|||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("tracks recently closed projects and drops them when reopened", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
projects.open("/a")
|
||||
projects.open("/b")
|
||||
projects.close("/a")
|
||||
expect(projects.recentlyClosed()).toEqual(["/a"])
|
||||
|
||||
projects.close("/b")
|
||||
expect(projects.recentlyClosed()).toEqual(["/b", "/a"])
|
||||
|
||||
projects.open("/a")
|
||||
expect(projects.recentlyClosed()).toEqual(["/b"])
|
||||
expect(projects.list()).toEqual([{ worktree: "/a", expanded: true }])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("remove drops a project without recording it as recently closed", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
projects.open("/repo/subdir")
|
||||
projects.remove("/repo/subdir")
|
||||
expect(projects.list()).toEqual([])
|
||||
expect(projects.recentlyClosed()).toEqual([])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("retains recently closed history beyond the visible display limit", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
// Closing 6 projects keeps all 6 in the store even though only 5 are displayed;
|
||||
// this prevents display-filtered entries from evicting still-visible ones.
|
||||
for (const dir of ["/1", "/2", "/3", "/4", "/5", "/6"]) {
|
||||
projects.open(dir)
|
||||
projects.close(dir)
|
||||
}
|
||||
expect(projects.recentlyClosed()).toEqual(["/6", "/5", "/4", "/3", "/2", "/1"])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("caps recently closed history at the store limit", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
projects.open(`/p${i}`)
|
||||
projects.close(`/p${i}`)
|
||||
}
|
||||
expect(projects.recentlyClosed()).toHaveLength(16)
|
||||
expect(projects.recentlyClosed()[0]).toBe("/p20")
|
||||
expect(projects.recentlyClosed().at(-1)).toBe("/p5")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("dedupes recently closed entries by normalized path", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
projects.close("/repo")
|
||||
projects.close("/repo/")
|
||||
expect(projects.recentlyClosed()).toEqual(["/repo/"])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateCanonicalLocalServerState", () => {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,23 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||
import { type Accessor, batch, createMemo } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
type StoredProject = { worktree: string; expanded: boolean }
|
||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||
type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
|
||||
type ServerProjectState = {
|
||||
projects: Record<string, StoredProject[]>
|
||||
lastProject: Record<string, string>
|
||||
recentlyClosed: Record<string, string[]>
|
||||
}
|
||||
const HEALTH_POLL_INTERVAL_MS = 10_000
|
||||
// The store retains more history than is displayed. Consumers filter recently closed entries
|
||||
// against the live project list (dropping deleted projects) and then cap the visible count via
|
||||
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
|
||||
// filtered out do not evict still-visible ones from the persisted store.
|
||||
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
|
||||
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
|
||||
|
||||
export function normalizeServerUrl(input: string) {
|
||||
const trimmed = input.trim()
|
||||
|
|
@ -72,19 +83,42 @@ export function createServerProjects<T extends ServerProjectState>(input: {
|
|||
}) {
|
||||
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
|
||||
const current = () => input.store.projects[input.scope()] ?? []
|
||||
const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
|
||||
const remove = (directory: string) => {
|
||||
setStore(
|
||||
"projects",
|
||||
input.scope(),
|
||||
current().filter((project) => project.worktree !== directory),
|
||||
)
|
||||
}
|
||||
return {
|
||||
list: current,
|
||||
recentlyClosed: currentClosed,
|
||||
remove,
|
||||
open(directory: string) {
|
||||
const scope = input.scope()
|
||||
const key = pathKey(directory)
|
||||
const closed = currentClosed()
|
||||
if (closed.some((worktree) => pathKey(worktree) === key)) {
|
||||
setStore(
|
||||
"recentlyClosed",
|
||||
scope,
|
||||
closed.filter((worktree) => pathKey(worktree) !== key),
|
||||
)
|
||||
}
|
||||
if (current().some((project) => project.worktree === directory)) return
|
||||
setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
|
||||
},
|
||||
// User-initiated close: removes the project and records it in recently closed.
|
||||
// Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove().
|
||||
close(directory: string) {
|
||||
setStore(
|
||||
"projects",
|
||||
input.scope(),
|
||||
current().filter((project) => project.worktree !== directory),
|
||||
remove(directory)
|
||||
const key = pathKey(directory)
|
||||
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
|
||||
0,
|
||||
RECENTLY_CLOSED_HISTORY_LIMIT,
|
||||
)
|
||||
setStore("recentlyClosed", input.scope(), closed)
|
||||
},
|
||||
expand(directory: string) {
|
||||
const index = current().findIndex((project) => project.worktree === directory)
|
||||
|
|
@ -235,6 +269,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||
list: [] as StoredServer[],
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
lastProject: {} as Record<string, string>,
|
||||
recentlyClosed: {} as Record<string, string[]>,
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -277,7 +312,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||
})
|
||||
}
|
||||
|
||||
const isReady = createMemo(() => ready() && !!state.active)
|
||||
const isReady = Object.assign(
|
||||
createMemo(() => ready() && !!state.active),
|
||||
{ promise: ready.promise },
|
||||
)
|
||||
|
||||
const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createTabMemory } from "./tab-memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||
import type { SessionTab, Tab } from "./tabs"
|
||||
import type { ServerConnection } from "./server"
|
||||
|
||||
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
||||
|
||||
function sessionTab(sessionId: string): SessionTab {
|
||||
return { type: "session", server, sessionId }
|
||||
}
|
||||
|
||||
describe("tab memory", () => {
|
||||
test("keeps state until its tab is removed", () => {
|
||||
|
|
@ -22,3 +31,75 @@ describe("tab memory", () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("closed tab stack", () => {
|
||||
test("records session tabs with their index", () => {
|
||||
const stack = pushClosedTab([], sessionTab("a"), 2)
|
||||
|
||||
expect(stack).toEqual([{ tab: sessionTab("a"), index: 2 }])
|
||||
})
|
||||
|
||||
test("ignores draft tabs", () => {
|
||||
const draft: Tab = { type: "draft", draftID: "d1", server, directory: "/tmp" }
|
||||
|
||||
expect(pushClosedTab([], draft, 0)).toEqual([])
|
||||
})
|
||||
|
||||
test("caps the stack size", () => {
|
||||
const stack = Array.from({ length: 30 }, (_, i) => i).reduce<ClosedTab[]>(
|
||||
(acc, i) => pushClosedTab(acc, sessionTab(`s${i}`), i),
|
||||
[],
|
||||
)
|
||||
|
||||
expect(stack).toHaveLength(25)
|
||||
expect(stack[0]?.tab.sessionId).toBe("s5")
|
||||
expect(stack.at(-1)?.tab.sessionId).toBe("s29")
|
||||
})
|
||||
|
||||
test("pops the most recently closed tab", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
{ tab: sessionTab("b"), index: 1 },
|
||||
]
|
||||
const result = takeClosedTab(stack, [])
|
||||
|
||||
expect(result.entry?.tab.sessionId).toBe("b")
|
||||
expect(result.stack).toEqual([{ tab: sessionTab("a"), index: 0 }])
|
||||
})
|
||||
|
||||
test("skips entries whose tab is already open", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
{ tab: sessionTab("b"), index: 1 },
|
||||
]
|
||||
const result = takeClosedTab(stack, [sessionTab("b")])
|
||||
|
||||
expect(result.entry?.tab.sessionId).toBe("a")
|
||||
expect(result.stack).toEqual([])
|
||||
})
|
||||
|
||||
test("returns no entry when everything is open or empty", () => {
|
||||
expect(takeClosedTab([], []).entry).toBeUndefined()
|
||||
|
||||
const result = takeClosedTab([{ tab: sessionTab("a"), index: 0 }], [sessionTab("a")])
|
||||
expect(result.entry).toBeUndefined()
|
||||
expect(result.stack).toEqual([])
|
||||
})
|
||||
|
||||
test("purges removed sessions", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
{ tab: sessionTab("b"), index: 1 },
|
||||
]
|
||||
|
||||
expect(removeClosedTabs(stack, server, ["a"])).toEqual([{ tab: sessionTab("b"), index: 1 }])
|
||||
})
|
||||
|
||||
test("does not navigate when a background tab closes", () => {
|
||||
const tabs = [sessionTab("a"), sessionTab("b"), sessionTab("c")]
|
||||
|
||||
expect(nextTabAfterClose(tabs, 1, false)).toBeUndefined()
|
||||
expect(nextTabAfterClose(tabs, 1, true)).toEqual(sessionTab("c"))
|
||||
expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { uuid } from "@/utils/uuid"
|
|||
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
import { createTabMemory } from "./tab-memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||
|
||||
export type SessionTab = {
|
||||
type: "session"
|
||||
|
|
@ -63,6 +64,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||
createStore<Tab[]>([]),
|
||||
)
|
||||
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
|
||||
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
|
||||
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -87,6 +89,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||
})
|
||||
}
|
||||
|
||||
const updateClosed = (update: (stack: ClosedTab[]) => ClosedTab[]) => {
|
||||
const apply = () => setClosed((stack) => update(stack))
|
||||
if (closedReady()) {
|
||||
apply()
|
||||
return
|
||||
}
|
||||
void closedReady.promise?.then(apply)
|
||||
}
|
||||
|
||||
const removeDraftPersisted = (draftID: string) => {
|
||||
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
||||
}
|
||||
|
|
@ -106,6 +117,13 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!closedReady()) return
|
||||
const servers = new Set(server.list.map(ServerConnection.key))
|
||||
const next = closed.filter((entry) => servers.has(entry.tab.server))
|
||||
if (next.length !== closed.length) setClosed(() => next)
|
||||
})
|
||||
|
||||
const navigateTab = (tab: Tab) => {
|
||||
const href = tabHref(tab)
|
||||
setRecentKey(tabKey(tab))
|
||||
|
|
@ -117,7 +135,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||
if (!tab) return
|
||||
const key = tabKey(tab)
|
||||
const draftID = tab.type === "draft" ? tab.draftID : undefined
|
||||
const nextTab = store[index + 1] ?? store[index - 1]
|
||||
const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/")
|
||||
closing.add(key)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
|
|
@ -125,9 +143,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||
tabs.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
|
||||
if (nextTab === null) {
|
||||
setRecentKey(undefined)
|
||||
navigate("/")
|
||||
}
|
||||
if (nextTab) navigateTab(nextTab)
|
||||
else navigate("/")
|
||||
}).finally(() => closing.delete(key))
|
||||
memory.remove(key)
|
||||
if (draftID) removeDraftPersisted(draftID)
|
||||
|
|
@ -201,13 +221,45 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||
removeDraftPersisted(draftID)
|
||||
},
|
||||
removeTab,
|
||||
// User-initiated close: records the tab so it can be reopened.
|
||||
// Cleanup paths (missing sessions, archive, server removal) go through
|
||||
// removeTab and friends directly and are not recorded.
|
||||
closeTab(index: number) {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index))
|
||||
removeTab(index)
|
||||
},
|
||||
reopenClosedTab() {
|
||||
if (!closedReady()) {
|
||||
void closedReady.promise?.then(() => actions.reopenClosedTab())
|
||||
return
|
||||
}
|
||||
const result = takeClosedTab(closed, store)
|
||||
if (result.stack.length === closed.length) return
|
||||
setClosed(() => result.stack)
|
||||
const entry = result.entry
|
||||
if (!entry) return
|
||||
const index = Math.min(entry.index, store.length)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
if (tabs.some((item) => tabKey(item) === tabKey(entry.tab))) return
|
||||
tabs.splice(index, 0, entry.tab)
|
||||
}),
|
||||
)
|
||||
navigateTab(entry.tab)
|
||||
})
|
||||
},
|
||||
removeSessionTab(input: Omit<SessionTab, "type">) {
|
||||
updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId]))
|
||||
const index = store.findIndex(
|
||||
(tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId,
|
||||
)
|
||||
if (index !== -1) removeTab(index)
|
||||
},
|
||||
removeServer(key: ServerConnection.Key) {
|
||||
updateClosed((stack) => stack.filter((entry) => entry.tab.server !== key))
|
||||
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
||||
const removed = store.filter((tab) => tab.server === key).map(tabKey)
|
||||
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
|
||||
|
|
@ -218,6 +270,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||
},
|
||||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||
const targetServer = input.server ?? server.key
|
||||
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
|
||||
const removed = store
|
||||
.filter(
|
||||
(tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue