mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 12:58:34 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 667e6594ce | |||
| d4e363e175 | |||
| 14c2f7cab2 | |||
| 01fbe4027e | |||
| cc53db4406 | |||
| 5e651053cc | |||
| 9d4b6c6589 | |||
| 216b479f7a | |||
| 6eece351c4 | |||
| 0f90793df2 | |||
| 46e832b1fd |
@@ -700,7 +700,6 @@
|
||||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"mermaid": "11.16.1",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.5",
|
||||
"remeda": "catalog:",
|
||||
|
||||
@@ -126,6 +126,13 @@ export async function setupTimeline(
|
||||
const transport = await installSseTransport<EventPayload>(page, {
|
||||
server,
|
||||
retry: input.eventRetry ?? 20,
|
||||
existingTools: messages.flatMap((message) =>
|
||||
"parts" in message
|
||||
? message.parts.flatMap((part) => (part.type === "tool" ? [part.callID] : []))
|
||||
: message.type === "assistant"
|
||||
? message.content.flatMap((part) => (part.type === "tool" ? [part.id] : []))
|
||||
: [],
|
||||
),
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
|
||||
@@ -38,12 +38,6 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
||||
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
||||
expect(
|
||||
requests.some((request) => {
|
||||
const url = new URL(request)
|
||||
return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("legacy session routes preserve an existing tab's server", async ({ page }) => {
|
||||
|
||||
@@ -119,15 +119,11 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
|
||||
await expectExpanded(wrapper, false)
|
||||
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: { part: streamedTextPart },
|
||||
},
|
||||
})
|
||||
events.push(...streamedTextEvents())
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
expect(await readToolState(page)).toEqual({
|
||||
expanded: false,
|
||||
@@ -151,15 +147,11 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await expectAppVisible(file)
|
||||
await markDiffProbe(page)
|
||||
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: { part: streamedTextPart },
|
||||
},
|
||||
})
|
||||
events.push(...streamedTextEvents())
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
const siblingProbe = await readDiffProbe(page)
|
||||
expect(siblingProbe).toEqual({
|
||||
fileMarker: "before",
|
||||
@@ -171,13 +163,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
})
|
||||
|
||||
await markDiffProbe(page)
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: { part: editPartWithAdditions(2) },
|
||||
},
|
||||
})
|
||||
events.push(...toolEvents(editPartWithAdditions(2)))
|
||||
|
||||
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
|
||||
{ timeout: 10_000 },
|
||||
@@ -293,7 +279,7 @@ async function readToolState(page: Page) {
|
||||
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
|
||||
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
|
||||
}),
|
||||
textPartID,
|
||||
`${assistantMessageID}:text:0`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -397,6 +383,39 @@ async function mockServer(page: Page, events: EventPayload[], messages = [userMe
|
||||
})
|
||||
}
|
||||
|
||||
function streamedTextEvents(): EventPayload[] {
|
||||
const data = { sessionID, assistantMessageID, ordinal: 0 }
|
||||
return [
|
||||
{ directory, payload: { type: "session.text.started", properties: data } },
|
||||
{ directory, payload: { type: "session.text.ended", properties: { ...data, text: streamedTextPart.text } } },
|
||||
]
|
||||
}
|
||||
|
||||
function toolEvents(part: typeof editPart): EventPayload[] {
|
||||
const data = { sessionID, assistantMessageID, id: part.callID }
|
||||
return [
|
||||
{
|
||||
directory,
|
||||
payload: {
|
||||
type: "session.tool.called",
|
||||
properties: { ...data, input: part.state.input, executed: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
directory,
|
||||
payload: {
|
||||
type: "session.tool.success",
|
||||
properties: {
|
||||
...data,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: part.state.metadata,
|
||||
executed: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function project() {
|
||||
return {
|
||||
id: projectID,
|
||||
|
||||
@@ -75,12 +75,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const [index, delay] of [120, 350, 80, 500].entries()) {
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: contextTool(
|
||||
const part = contextTool(
|
||||
contextIDs[index]!,
|
||||
id("msg_assistant", 10),
|
||||
["read", "glob", "grep", "list"][index]!,
|
||||
@@ -90,10 +85,36 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
{ path: directory, pattern: "Explored" },
|
||||
{ path: "src" },
|
||||
][index]!,
|
||||
),
|
||||
)
|
||||
events.push(
|
||||
{
|
||||
directory,
|
||||
payload: {
|
||||
type: "session.tool.called",
|
||||
properties: {
|
||||
sessionID,
|
||||
assistantMessageID: part.messageID,
|
||||
id: part.callID,
|
||||
input: part.state.input,
|
||||
executed: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
{
|
||||
directory,
|
||||
payload: {
|
||||
type: "session.tool.success",
|
||||
properties: {
|
||||
sessionID,
|
||||
assistantMessageID: part.messageID,
|
||||
id: part.callID,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: part.state.metadata,
|
||||
executed: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
await page.waitForTimeout(delay)
|
||||
}
|
||||
|
||||
|
||||
@@ -95,16 +95,16 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
||||
await timeline.send(status("retry"), 180)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(status("busy", 2), 180)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
||||
await timeline.send(status("idle"), 350)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id="prt_recovered"]')).toContainText("Recovered response")
|
||||
await expect(page.locator('[data-timeline-part-id="msg_1001_timeline_assistant:text:0"]')).toContainText(
|
||||
"Recovered response",
|
||||
)
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
|
||||
@@ -203,7 +203,12 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
|
||||
).toHaveAttribute("data-active", "true")
|
||||
|
||||
await timeline.send(event("session.status", { sessionID: backgroundID, status: { type: "idle" } }))
|
||||
await timeline.transport.send({
|
||||
id: "evt_background_succeeded",
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: backgroundID },
|
||||
} as never)
|
||||
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
|
||||
await expect(backgroundCard).toContainText("Background task (background)")
|
||||
})
|
||||
|
||||
@@ -39,5 +39,7 @@ test("reducer-hardening: converges when idle arrives before final part and messa
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle")
|
||||
await expect(page.locator('[data-timeline-part-id="msg_1001_timeline_assistant:text:1"]')).toContainText(
|
||||
"Final after early idle",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { partUpdated, setupTimeline, textPart } from "../performance/timeline-stability/fixture"
|
||||
|
||||
const streamedTextID = (ordinal: number) => `msg_1001_timeline_assistant:text:${ordinal}`
|
||||
|
||||
test("keeps one connection open while delivering multiple events", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event")))
|
||||
const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event")))
|
||||
|
||||
await timeline.waitForPart("prt_transport_first")
|
||||
await timeline.waitForPart("prt_transport_second")
|
||||
await timeline.waitForPart(streamedTextID(0))
|
||||
await timeline.waitForPart(streamedTextID(1))
|
||||
expect(first.connectionID).toBe(second.connectionID)
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
|
||||
@@ -21,8 +23,8 @@ test("delivers a burst from one stream chunk", async ({ page }) => {
|
||||
partUpdated(textPart("prt_transport_burst_b", "burst b")),
|
||||
])
|
||||
|
||||
await timeline.waitForPart("prt_transport_burst_a")
|
||||
await timeline.waitForPart("prt_transport_burst_b")
|
||||
await timeline.waitForPart(streamedTextID(0))
|
||||
await timeline.waitForPart(streamedTextID(1))
|
||||
expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1])
|
||||
expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2)
|
||||
})
|
||||
@@ -36,8 +38,8 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
||||
|
||||
const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2])
|
||||
|
||||
await timeline.waitForPart("prt_transport_split")
|
||||
await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText(
|
||||
await timeline.waitForPart(streamedTextID(0))
|
||||
await expect(page.locator(`[data-timeline-part-id="${streamedTextID(0)}"]`)).toContainText(
|
||||
"split snowman \u2603\u2603\u2603",
|
||||
)
|
||||
expect(acknowledgement.chunkCount).toBe(4)
|
||||
@@ -45,10 +47,9 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
||||
|
||||
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
const partID = "prt_transport_heartbeat_sentinel"
|
||||
const sentinel = await timeline.transport.send(partUpdated(textPart(partID, "heartbeat sentinel")))
|
||||
await timeline.waitForPart(partID)
|
||||
await expect(page.locator(`[data-timeline-part-id="${partID}"] [data-component="markdown"]`)).toHaveAttribute(
|
||||
const sentinel = await timeline.transport.send(partUpdated(textPart("prt_transport_heartbeat_sentinel", "heartbeat sentinel")))
|
||||
await timeline.waitForPart(streamedTextID(0))
|
||||
await expect(page.locator(`[data-timeline-part-id="${streamedTextID(0)}"] [data-component="markdown"]`)).toHaveAttribute(
|
||||
"data-markdown-ready",
|
||||
"",
|
||||
)
|
||||
@@ -68,7 +69,7 @@ test("reconnects after a clean close", async ({ page }) => {
|
||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||
await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close")))
|
||||
|
||||
await timeline.waitForPart("prt_transport_close")
|
||||
await timeline.waitForPart(streamedTextID(0))
|
||||
expect(second.id).toBeGreaterThan(first.id)
|
||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("close")
|
||||
})
|
||||
@@ -81,7 +82,7 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||
await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error")))
|
||||
|
||||
await timeline.waitForPart("prt_transport_error")
|
||||
await timeline.waitForPart(streamedTextID(0))
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
|
||||
expect(second.id).toBeGreaterThan(first.id)
|
||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
||||
@@ -92,7 +93,7 @@ test("does not request replay when reconnecting the volatile V2 event stream", a
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
await timeline.waitForPart("prt_transport_id")
|
||||
await timeline.waitForPart(streamedTextID(0))
|
||||
|
||||
await timeline.transport.error("retry with event id")
|
||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/TodoDockNavigation"
|
||||
const projectID = "proj_todo_dock_navigation"
|
||||
const sourceID = "ses_todo_dock_source"
|
||||
const otherID = "ses_todo_dock_other"
|
||||
const sourceTitle = "Todo dock animation"
|
||||
const otherTitle = "Separate session"
|
||||
|
||||
const activeTodos = [
|
||||
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
||||
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
||||
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
||||
]
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
|
||||
test.setTimeout(90_000)
|
||||
const events: EventPayload[] = []
|
||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "todo-dock-navigation",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"claude-opus-4-6": {
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
sessionStatus: () => sessionStatus,
|
||||
todos: (sessionID) => todos[sessionID] ?? [],
|
||||
})
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(sessionHref(sourceID))
|
||||
await expectSessionTitle(page, sourceTitle)
|
||||
const dock = page.locator('[data-component="session-todo-dock"]')
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
sessionStatus[sourceID] = { type: "busy" }
|
||||
events.push(statusEvent(sourceID, "busy"))
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
|
||||
await page.waitForTimeout(700)
|
||||
const opening = sampleDock(page, 1_000)
|
||||
todos[sourceID] = activeTodos
|
||||
events.push(todoEvent(sourceID, activeTodos))
|
||||
await expect(dock).toBeVisible()
|
||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
})
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||
}
|
||||
}
|
||||
|
||||
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
|
||||
}
|
||||
}
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript(
|
||||
({ directory, dirBase64, server, sessionIDs }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
|
||||
)
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
const href = sessionHref(sessionID)
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
||||
function sampleDock(page: Page, duration: number) {
|
||||
return page.evaluate(async (duration) => {
|
||||
const samples: { present: boolean; height: number; opacity: number }[] = []
|
||||
const start = performance.now()
|
||||
while (performance.now() - start < duration) {
|
||||
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
|
||||
const clip = dock?.parentElement?.parentElement
|
||||
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
|
||||
samples.push({
|
||||
present: !!dock,
|
||||
height: clip?.getBoundingClientRect().height ?? 0,
|
||||
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
|
||||
})
|
||||
await new Promise(requestAnimationFrame)
|
||||
}
|
||||
return samples
|
||||
}, duration)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ test("navigates to a subagent child session missing from the session list", asyn
|
||||
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("keeps the parent visible while child lineage resolves", async ({ page }) => {
|
||||
test("keeps the parent visible while the child session resolves", async ({ page }) => {
|
||||
await setup(page)
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
@@ -59,7 +59,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({
|
||||
|
||||
events.push({
|
||||
directory,
|
||||
payload: { type: "session.deleted", properties: { info: childSession() } },
|
||||
payload: { type: "session.deleted", properties: { sessionID: childID } },
|
||||
})
|
||||
|
||||
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
||||
|
||||
@@ -29,7 +29,6 @@ export interface MockServerConfig {
|
||||
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||
events?: () => unknown[]
|
||||
eventRetry?: number
|
||||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
forms?: unknown[] | (() => unknown[])
|
||||
@@ -303,6 +302,16 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
currentPermission,
|
||||
),
|
||||
})
|
||||
const sessionPermissions = path.match(/^\/api\/session\/([^/]+)\/permission$/)?.[1]
|
||||
if (sessionPermissions)
|
||||
return json(
|
||||
route,
|
||||
{
|
||||
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
.map(currentPermission)
|
||||
.filter((permission) => permission.sessionID === sessionPermissions),
|
||||
},
|
||||
)
|
||||
if (path === "/api/question/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
@@ -466,8 +475,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, message)
|
||||
}
|
||||
|
||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
|
||||
@@ -57,11 +57,11 @@ type BrowserTransport = Window & {
|
||||
|
||||
export async function installSseTransport<T>(
|
||||
page: Page,
|
||||
options: { server: string; retry?: number },
|
||||
options: { server: string; retry?: number; existingTools?: readonly string[] },
|
||||
): Promise<SseTransport<T>> {
|
||||
const server = new URL(options.server).origin
|
||||
await page.addInitScript(
|
||||
({ server, retry }) => {
|
||||
({ server, retry, existingTools }) => {
|
||||
type Connection = SseConnectionRecord & { controller: ReadableStreamDefaultController<Uint8Array> }
|
||||
type ProbeWindow = Window & {
|
||||
__visualStabilityProbe?: { startedAt: number; markers: { at: number; label: string }[] }
|
||||
@@ -72,6 +72,9 @@ export async function installSseTransport<T>(
|
||||
const encoder = new TextEncoder()
|
||||
let nextConnectionID = 0
|
||||
let nextDeliveryID = 0
|
||||
const textOrdinals = new Map<string, number>()
|
||||
const reasoningOrdinals = new Map<string, number>()
|
||||
const startedTools = new Set(existingTools)
|
||||
|
||||
const current = () => connections.findLast((connection) => connection.endedAt === undefined)
|
||||
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
|
||||
@@ -93,20 +96,169 @@ export async function installSseTransport<T>(
|
||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||
`data: ${JSON.stringify(payload)}\n\n`,
|
||||
].join("")
|
||||
const currentEvent = (input: unknown) => {
|
||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
||||
const currentEvents = (input: unknown) => {
|
||||
if (!input || typeof input !== "object" || !("payload" in input)) return [input]
|
||||
const envelope = input as { directory?: string; payload?: unknown }
|
||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
||||
if (!envelope.payload || typeof envelope.payload !== "object") return [input]
|
||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
||||
if (!payload.type) return input
|
||||
return {
|
||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
||||
if (!payload.type) return [input]
|
||||
const properties = (payload.properties ?? {}) as Record<string, unknown>
|
||||
const base = {
|
||||
created: Date.now(),
|
||||
type: payload.type,
|
||||
data: payload.properties ?? {},
|
||||
location:
|
||||
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||
}
|
||||
const events = (items: { type: string; data: Record<string, unknown> }[]) =>
|
||||
items.map((item, index) => ({
|
||||
...base,
|
||||
id: `${payload.id ?? `evt_mock_${Date.now()}`}${index ? `_${index}` : ""}`,
|
||||
...item,
|
||||
}))
|
||||
if (payload.type === "session.status") {
|
||||
const status = properties.status as { type?: string; attempt?: number; message?: string; next?: number }
|
||||
if (status.type === "busy")
|
||||
return events([{ type: "session.execution.started", data: { sessionID: properties.sessionID } }])
|
||||
if (status.type === "retry")
|
||||
return events([
|
||||
{
|
||||
type: "session.retry.scheduled",
|
||||
data: {
|
||||
sessionID: properties.sessionID,
|
||||
assistantMessageID: "msg_1001_timeline_assistant",
|
||||
attempt: status.attempt ?? 1,
|
||||
at: status.next ?? Date.now(),
|
||||
error: { type: "provider.error", message: status.message ?? "Retry scheduled" },
|
||||
},
|
||||
},
|
||||
])
|
||||
return events([{ type: "session.execution.succeeded", data: { sessionID: properties.sessionID } }])
|
||||
}
|
||||
if (payload.type === "message.updated") {
|
||||
const info = properties.info as Record<string, unknown>
|
||||
const time = info.time as { completed?: number } | undefined
|
||||
if (info.role !== "assistant" || time?.completed === undefined) return []
|
||||
const error = info.error as { name?: string; data?: { message?: string } } | undefined
|
||||
if (error)
|
||||
return events([
|
||||
{
|
||||
type: "session.step.failed",
|
||||
data: {
|
||||
sessionID: properties.sessionID,
|
||||
assistantMessageID: info.id,
|
||||
error: { type: error.name ?? "session.error", message: error.data?.message ?? "Session failed" },
|
||||
cost: info.cost ?? 0,
|
||||
tokens: info.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
},
|
||||
])
|
||||
return events([
|
||||
{
|
||||
type: "session.step.ended",
|
||||
data: {
|
||||
sessionID: properties.sessionID,
|
||||
assistantMessageID: info.id,
|
||||
finish: "stop",
|
||||
cost: info.cost ?? 0,
|
||||
tokens: info.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = properties.part as Record<string, unknown>
|
||||
const sessionID = properties.sessionID
|
||||
const assistantMessageID = part.messageID
|
||||
if (part.type === "text") {
|
||||
const existing = document.querySelector(`[data-timeline-part-id="${String(part.id)}"]`)
|
||||
if (existing) {
|
||||
const parts = Array.from(
|
||||
document.querySelectorAll(`[data-message-id="${assistantMessageID}"] [data-component="text-part"]`),
|
||||
)
|
||||
return events([
|
||||
{
|
||||
type: "session.text.ended",
|
||||
data: { sessionID, assistantMessageID, ordinal: parts.indexOf(existing), text: part.text },
|
||||
},
|
||||
])
|
||||
}
|
||||
const ordinal =
|
||||
textOrdinals.get(String(assistantMessageID)) ??
|
||||
document.querySelectorAll(`[data-message-id="${assistantMessageID}"] [data-component="text-part"]`).length
|
||||
textOrdinals.set(String(assistantMessageID), ordinal + 1)
|
||||
return events([
|
||||
{ type: "session.text.started", data: { sessionID, assistantMessageID, ordinal } },
|
||||
{ type: "session.text.ended", data: { sessionID, assistantMessageID, ordinal, text: part.text } },
|
||||
])
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
const ordinal =
|
||||
reasoningOrdinals.get(String(assistantMessageID)) ??
|
||||
document.querySelectorAll(`[data-message-id="${assistantMessageID}"] [data-component="reasoning-part"]`)
|
||||
.length
|
||||
reasoningOrdinals.set(String(assistantMessageID), ordinal + 1)
|
||||
return events([
|
||||
{ type: "session.reasoning.started", data: { sessionID, assistantMessageID, ordinal } },
|
||||
{ type: "session.reasoning.ended", data: { sessionID, assistantMessageID, ordinal, text: part.text } },
|
||||
])
|
||||
}
|
||||
if (part.type === "tool") {
|
||||
const state = part.state as Record<string, unknown>
|
||||
const id = part.callID ?? part.id
|
||||
const toolID = String(id)
|
||||
const exists =
|
||||
startedTools.has(toolID) ||
|
||||
document.querySelector(`[data-timeline-part-id="${String(part.id)}"]`) !== null ||
|
||||
Array.from(document.querySelectorAll("[data-timeline-part-ids]")).some((element) =>
|
||||
element.getAttribute("data-timeline-part-ids")?.split(",").includes(String(part.id)),
|
||||
)
|
||||
startedTools.add(toolID)
|
||||
const started = exists
|
||||
? []
|
||||
: [{ type: "session.tool.input.started", data: { sessionID, assistantMessageID, id, name: part.tool } }]
|
||||
if (state.status === "pending") return events(started)
|
||||
const called = {
|
||||
type: "session.tool.called",
|
||||
data: { sessionID, assistantMessageID, id, input: state.input ?? {}, executed: false },
|
||||
}
|
||||
if (state.status === "running")
|
||||
return events([
|
||||
...started,
|
||||
called,
|
||||
{ type: "session.tool.progress", data: { sessionID, assistantMessageID, id, metadata: state.metadata ?? {} } },
|
||||
])
|
||||
if (state.status === "error")
|
||||
return events([
|
||||
...started,
|
||||
called,
|
||||
{
|
||||
type: "session.tool.failed",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id,
|
||||
error: { type: "tool.error", message: state.error ?? "Tool failed" },
|
||||
metadata: state.metadata ?? {},
|
||||
executed: false,
|
||||
},
|
||||
},
|
||||
])
|
||||
return events([
|
||||
...started,
|
||||
called,
|
||||
{
|
||||
type: "session.tool.success",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id,
|
||||
content: [{ type: "text", text: state.output ?? "" }],
|
||||
metadata: state.metadata ?? {},
|
||||
executed: false,
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
return events([{ type: payload.type, data: properties }])
|
||||
}
|
||||
const acknowledge = (
|
||||
connection: Connection,
|
||||
@@ -156,12 +308,15 @@ export async function installSseTransport<T>(
|
||||
return acknowledge(connection, input.bytes.length, output.length)
|
||||
}
|
||||
const encoded = input.deliveries.map((delivery) => {
|
||||
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
|
||||
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
|
||||
const payloads = connection.path === "/api/event" ? currentEvents(delivery.payload) : [delivery.payload]
|
||||
return {
|
||||
delivery,
|
||||
bytes: encoder.encode(payloads.map((payload) => frame(payload, delivery.options)).join("")),
|
||||
}
|
||||
})
|
||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||
if (input.burst) {
|
||||
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
|
||||
const bytes = encoder.encode(encoded.map((item) => new TextDecoder().decode(item.bytes)).join(""))
|
||||
connection.controller.enqueue(bytes)
|
||||
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
||||
}
|
||||
@@ -221,7 +376,7 @@ export async function installSseTransport<T>(
|
||||
}
|
||||
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
|
||||
},
|
||||
{ server, retry: options.retry },
|
||||
{ server, retry: options.retry, existingTools: options.existingTools ?? [] },
|
||||
)
|
||||
|
||||
const command = <Result>(input: BrowserCommand<T>) =>
|
||||
|
||||
+18
-23
@@ -35,9 +35,9 @@ import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServersProvider, useServers } from "@/context/servers"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { LocationProvider } from "@/context/location"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import { SessionUIProvider } from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
@@ -66,19 +66,16 @@ const DirectoryDraftRedirect = () => {
|
||||
}
|
||||
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const params = useParams<{ serverKey: string }>()
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() => {
|
||||
const key = requireServerKey(params.serverKey)
|
||||
return global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||
})
|
||||
const conn = createMemo(() =>
|
||||
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
|
||||
)
|
||||
|
||||
return (
|
||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<Show when={conn()} keyed>
|
||||
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
|
||||
</Show>
|
||||
<Show when={conn()} keyed>
|
||||
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -87,14 +84,12 @@ function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
<Show
|
||||
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
|
||||
keyed
|
||||
fallback={<Navigate href="/" />}
|
||||
>
|
||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||
</Show>
|
||||
<Show
|
||||
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
|
||||
keyed
|
||||
fallback={tabs.ready() && <Navigate href="/" />}
|
||||
>
|
||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -109,13 +104,13 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
{(conn) => (
|
||||
<ServerProvider conn={conn}>
|
||||
<ModelsProvider directory={props.draft.directory}>
|
||||
<SDKProvider directory={props.draft.directory}>
|
||||
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<LocationProvider directory={props.draft.directory}>
|
||||
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<DraftProviders>
|
||||
<NewSession draftId={props.draft.draftID} />
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</SessionUIProvider>
|
||||
</LocationProvider>
|
||||
</ModelsProvider>
|
||||
</ServerProvider>
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { type Component, createMemo, createUniqueId, For, Match, onMount, Show,
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { ExternalLink } from "@/components/external-link"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useIntegrations } from "@/hooks/use-integrations"
|
||||
@@ -258,7 +257,6 @@ function ProviderConnection(props: {
|
||||
setBack: (handler: () => void) => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders(() => props.directory)
|
||||
@@ -280,10 +278,7 @@ function ProviderConnection(props: {
|
||||
const provider = createMemo(() => ({
|
||||
id: props.provider,
|
||||
name:
|
||||
providers.all().get(props.provider)?.name ??
|
||||
serverSync.data.provider.all.get(props.provider)?.name ??
|
||||
controller.integration()?.name ??
|
||||
props.provider,
|
||||
providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
|
||||
}))
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
|
||||
@@ -9,7 +9,7 @@ import { showToast } from "@/utils/toast"
|
||||
import { batch, For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { ExternalLink } from "@/components/external-link"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||
|
||||
@@ -41,7 +41,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
|
||||
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const language = useLanguage()
|
||||
|
||||
const [form, setForm] = createStore<FormState>({
|
||||
@@ -116,8 +116,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||
const output = validateCustomProvider({
|
||||
form,
|
||||
t: language.t,
|
||||
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
|
||||
// TODO: Restore disabled-provider validation when V2 exposes config reads.
|
||||
disabledProviders: [],
|
||||
existingProviderIDs: new Set((data.location.provider.list() ?? []).map((provider) => provider.id)),
|
||||
})
|
||||
batch(() => {
|
||||
setForm("err", output.err)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { For, Show, createSignal, startTransition } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { LocationProvider } from "@/context/location"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { createEditProjectModel } from "./edit-project"
|
||||
import { ProjectSettingsExtensions } from "./project-settings-extensions"
|
||||
@@ -22,9 +22,9 @@ import "./dialog-edit-project-v2.css"
|
||||
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
return (
|
||||
<SettingsServerDataScope server={props.server}>
|
||||
<SDKProvider directory={props.project.worktree}>
|
||||
<LocationProvider directory={props.project.worktree}>
|
||||
<ProjectSettingsDialog project={props.project} server={props.server} />
|
||||
</SDKProvider>
|
||||
</LocationProvider>
|
||||
</SettingsServerDataScope>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Component, createMemo } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useData } from "@/context/server"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import type { TextPart as SDKTextPart } from "@/types"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { normalizeSessionMessages } from "@/utils/session-message"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
|
||||
interface ForkableMessage {
|
||||
id: string
|
||||
@@ -25,8 +28,9 @@ function formatTime(date: Date): string {
|
||||
export const DialogFork: Component = () => {
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
const location = useWorkspaceLocation()
|
||||
const prompt = usePrompt()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
@@ -35,19 +39,15 @@ export const DialogFork: Component = () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return []
|
||||
|
||||
const msgs = sync().data.message[sessionID] ?? []
|
||||
const msgs = data.session.message.list(sessionID)
|
||||
const result: ForkableMessage[] = []
|
||||
|
||||
for (const message of msgs) {
|
||||
if (message.role !== "user") continue
|
||||
|
||||
const parts = sync().data.part[message.id] ?? []
|
||||
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
|
||||
if (!textPart) continue
|
||||
if (message.type !== "user" || !message.text) continue
|
||||
|
||||
result.push({
|
||||
id: message.id,
|
||||
text: textPart.text.replace(/\n/g, " ").slice(0, 200),
|
||||
text: message.text.replace(/\n/g, " ").slice(0, 200),
|
||||
time: formatTime(new Date(message.time.created)),
|
||||
})
|
||||
}
|
||||
@@ -60,20 +60,21 @@ export const DialogFork: Component = () => {
|
||||
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const restored = extractPromptFromParts(
|
||||
normalizeSessionMessages(sessionID, data.session.message.list(sessionID)).parts.get(item.id) ?? [],
|
||||
{
|
||||
directory: location().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
},
|
||||
)
|
||||
|
||||
const parts = sync().data.part[item.id] ?? []
|
||||
const restored = extractPromptFromParts(parts, {
|
||||
directory: sdk().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
const dir = base64Encode(sdk().directory)
|
||||
|
||||
sdk()
|
||||
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
||||
serverSDK.api.session
|
||||
.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
||||
.then((forked) => {
|
||||
data.session.remember(forked)
|
||||
dialog.close()
|
||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||
navigate(`/${dir}/session/${forked.id}`)
|
||||
prompt.set(restored, undefined, { dir: base64Encode(location().directory), id: forked.id })
|
||||
navigate(sessionHref(ServerConnection.key(serverSDK.server), forked.id))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, createMemo, Show } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
@@ -14,12 +15,13 @@ const statusLabels = {
|
||||
} as const
|
||||
|
||||
export const DialogSelectMcp: Component = () => {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const language = useLanguage()
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync().data.mcp ?? {})
|
||||
.map(([name, status]) => ({ name, status: status.status }))
|
||||
(data.location.mcp.server.list({ directory: sdk().directory }) ?? [])
|
||||
.map((server) => ({ name: server.name, status: server.status.status }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
@@ -47,7 +49,8 @@ export const DialogSelectMcp: Component = () => {
|
||||
}}
|
||||
>
|
||||
{(i) => {
|
||||
const mcpStatus = () => sync().data.mcp[i.name]
|
||||
const mcpStatus = () =>
|
||||
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)?.status
|
||||
const status = () => mcpStatus()?.status
|
||||
const statusLabel = () => {
|
||||
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
|
||||
import { type Component, For, Show, createEffect, createMemo, createResource, createSignal } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { ExternalLink } from "./external-link"
|
||||
|
||||
type SkillItem = {
|
||||
@@ -15,7 +14,6 @@ type SkillItem = {
|
||||
location: string
|
||||
}
|
||||
|
||||
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
|
||||
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
|
||||
|
||||
const ExtensionCard: Component<{ children: unknown }> = (props) => (
|
||||
@@ -23,9 +21,8 @@ const ExtensionCard: Component<{ children: unknown }> = (props) => (
|
||||
)
|
||||
|
||||
const ExtensionRow: Component<{
|
||||
icon: "mcp" | "cube" | "post-skill" | "code"
|
||||
icon: "mcp" | "cube" | "post-skill"
|
||||
name: string
|
||||
status?: string
|
||||
children?: unknown
|
||||
}> = (props) => (
|
||||
<div class="project-settings-extension-row">
|
||||
@@ -33,14 +30,6 @@ const ExtensionRow: Component<{
|
||||
<Icon name={props.icon} class="project-settings-extension-row-icon" />
|
||||
<span class="project-settings-extension-row-name">{props.name}</span>
|
||||
</div>
|
||||
<Show when={props.status}>
|
||||
{(status) => (
|
||||
<span class="project-settings-extension-row-status">
|
||||
<span class="project-settings-extension-row-status-dot" />
|
||||
{status()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
{props.children as any}
|
||||
</div>
|
||||
)
|
||||
@@ -75,56 +64,55 @@ const SharedSection: Component<{
|
||||
export const ProjectSettingsExtensions: Component = () => {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const directorySDK = useSDK()
|
||||
const serverSync = useServerSync()
|
||||
const sync = useSync()
|
||||
const toggleMcp = useMcpToggle()
|
||||
const directorySDK = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const toggleMcp = useMcpToggle(() => directorySDK().directory)
|
||||
|
||||
createEffect(() => {
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
const ref = { directory: directorySDK().directory }
|
||||
void Promise.all([
|
||||
data.location.mcp.server.sync(),
|
||||
data.location.skill.sync(),
|
||||
data.location.mcp.server.sync(ref),
|
||||
data.location.skill.sync(ref),
|
||||
]).catch(() => undefined)
|
||||
})
|
||||
|
||||
const [serverMcp] = createResource(
|
||||
serverSDK,
|
||||
(sdk) =>
|
||||
sdk.api.mcp
|
||||
.list()
|
||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
||||
.catch(() => ({})),
|
||||
{ initialValue: {} },
|
||||
)
|
||||
const globalMcpNames = createMemo(() =>
|
||||
[...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
|
||||
[...new Set((data.location.mcp.server.list() ?? []).map((server) => server.name))].sort(),
|
||||
)
|
||||
const projectMcpNames = createMemo(() => {
|
||||
const shared = new Set(globalMcpNames())
|
||||
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
|
||||
if (configured.length > 0) return configured.sort()
|
||||
return Object.keys(sync().data.mcp ?? {})
|
||||
return (data.location.mcp.server.list({ directory: directorySDK().directory }) ?? [])
|
||||
.map((server) => server.name)
|
||||
.filter((name) => !shared.has(name))
|
||||
.sort()
|
||||
})
|
||||
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
|
||||
const mcpEnabled = (name: string) =>
|
||||
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)?.status
|
||||
.status === "connected"
|
||||
|
||||
const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
|
||||
const [globalPluginList] = createResource(
|
||||
() => serverSDK.connection.status() === "connected",
|
||||
() => serverSDK.api.plugin.list().then((result) => result.data),
|
||||
)
|
||||
const [projectPluginList] = createResource(
|
||||
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
|
||||
const projectPlugins = createMemo(() => {
|
||||
const shared = new Set(globalPlugins())
|
||||
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
||||
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
|
||||
})
|
||||
|
||||
const [serverSkills] = createResource(
|
||||
serverSDK,
|
||||
(sdk): Promise<SkillItem[]> =>
|
||||
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
const [directorySkills] = createResource(
|
||||
directorySDK,
|
||||
(sdk): Promise<SkillItem[]> =>
|
||||
sdk.api.skill
|
||||
.list({ location: { directory: sdk.directory } })
|
||||
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
||||
const projectSkills = createMemo(() => {
|
||||
const shared = new Set(serverSkills.latest.map(skillKey))
|
||||
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
|
||||
const shared = new Set(serverSkills().map(skillKey))
|
||||
return (data.location.skill.list({ directory: directorySDK().directory }) ?? []).filter(
|
||||
(item) => !shared.has(skillKey(item)),
|
||||
)
|
||||
})
|
||||
|
||||
const mcpRows = (items: string[]) => (
|
||||
@@ -165,7 +153,7 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
|
||||
{/* TODO: Restore LSP status when V2 exposes it. */}
|
||||
</TabsV2.List>
|
||||
|
||||
<TabsV2.Content value="mcps">
|
||||
@@ -205,33 +193,10 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
<Show when={projectSkills().length > 0}>
|
||||
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
|
||||
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="lsps">
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
|
||||
<span>{language.t("project.settings.extensions.lsp.description")}</span>
|
||||
</div>
|
||||
<Show when={sync().data.lsp.length > 0}>
|
||||
<ExtensionCard>
|
||||
<For each={sync().data.lsp}>
|
||||
{(item) => (
|
||||
<ExtensionRow
|
||||
icon="code"
|
||||
name={item.name || item.id}
|
||||
status={
|
||||
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</ExtensionCard>
|
||||
</Show>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
</TabsV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -22,8 +22,8 @@ import { useLayout } from "@/context/layout"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useData } from "@/context/server"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
|
||||
@@ -81,8 +81,8 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||
}
|
||||
|
||||
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const files = useFile()
|
||||
const layout = useLayout()
|
||||
const comments = useComments()
|
||||
@@ -113,8 +113,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
return [...result, path]
|
||||
}, [])
|
||||
})
|
||||
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
||||
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
||||
const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
|
||||
const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
|
||||
const attachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
@@ -228,8 +228,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
const referenceDescription = (reference: ReferenceInfo) =>
|
||||
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
||||
const references = createMemo(() =>
|
||||
sync()
|
||||
.data.reference.filter((reference) => !reference.hidden)
|
||||
(data.location.reference.list({ directory: sdk().directory }) ?? [])
|
||||
.filter((reference) => !reference.hidden)
|
||||
.map((reference) => ({
|
||||
id: `reference:${reference.name}`,
|
||||
kind: "reference" as const,
|
||||
@@ -248,7 +248,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
})),
|
||||
)
|
||||
const resources = createMemo(() =>
|
||||
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||
(data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map((resource) => ({
|
||||
id: `resource:${resource.server}:${resource.uri}`,
|
||||
kind: "resource" as const,
|
||||
label: `@${resource.name}`,
|
||||
@@ -294,7 +294,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
})),
|
||||
])
|
||||
const slashCommands = createMemo(() => [
|
||||
...sync().data.command.map((item) => ({
|
||||
...(data.location.command.list({ directory: sdk().directory }) ?? []).map((item) => ({
|
||||
id: `custom.${item.name}`,
|
||||
trigger: item.name,
|
||||
title: item.name,
|
||||
@@ -354,7 +354,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
if (item) openComment(item, props, sync, layout, files, comments)
|
||||
if (item) openComment(item, props, layout, files, comments)
|
||||
},
|
||||
onEditor(element) {
|
||||
editor = element as HTMLDivElement
|
||||
@@ -557,7 +557,6 @@ function PromptInputV2ModelControl(props: {
|
||||
function openComment(
|
||||
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
|
||||
props: PromptInputV2ControllerProps,
|
||||
sync: ReturnType<typeof useSync>,
|
||||
layout: ReturnType<typeof useLayout>,
|
||||
files: ReturnType<typeof useFile>,
|
||||
comments: ReturnType<typeof useComments>,
|
||||
@@ -575,9 +574,7 @@ function openComment(
|
||||
})
|
||||
})
|
||||
}
|
||||
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
|
||||
const review =
|
||||
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
|
||||
const review = item.commentOrigin === "review"
|
||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||
if (review) {
|
||||
layout.fileTree.setTab("changes")
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@/types"
|
||||
import { createPromptState } from "@/context/prompt"
|
||||
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
|
||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||
|
||||
function createPromptInputStoryRuntime() {
|
||||
@@ -111,93 +109,6 @@ function PromptInputExample() {
|
||||
)
|
||||
}
|
||||
|
||||
const todos: Todo[] = [
|
||||
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
|
||||
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
|
||||
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
|
||||
]
|
||||
|
||||
function PromptInputWithOpenDock() {
|
||||
const input = createPromptInputStoryRuntime()
|
||||
const [controls, setControls] = createStore({
|
||||
agent: "build",
|
||||
activeTab: undefined as string | undefined,
|
||||
todoCollapsed: false,
|
||||
})
|
||||
const inputControls = {
|
||||
agents: {
|
||||
available: [],
|
||||
options: ["build"],
|
||||
get current() {
|
||||
return controls.agent
|
||||
},
|
||||
loading: false,
|
||||
visible: true,
|
||||
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
||||
},
|
||||
model: {
|
||||
selection: {
|
||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||
},
|
||||
paid: true,
|
||||
loading: false,
|
||||
},
|
||||
session: {
|
||||
id: "story-session",
|
||||
tabs: {
|
||||
active: () => controls.activeTab,
|
||||
all: () => [],
|
||||
open: () => {},
|
||||
setActive: (tab: string) => setControls("activeTab", tab),
|
||||
},
|
||||
reviewPanel: { opened: () => false, open: () => {} },
|
||||
},
|
||||
}
|
||||
const state = {
|
||||
blocked: () => false,
|
||||
questionRequest: () => undefined,
|
||||
permissionRequest: () => undefined,
|
||||
permissionResponding: () => false,
|
||||
decide: () => {},
|
||||
todos: () => todos,
|
||||
dock: () => true,
|
||||
closing: () => false,
|
||||
opening: () => false,
|
||||
}
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={createSessionComposerRegionController({
|
||||
state,
|
||||
sessionKey: () => "story-session",
|
||||
sessionID: () => "story-session",
|
||||
prompt: input.state,
|
||||
ready: () => true,
|
||||
centered: () => false,
|
||||
todo: {
|
||||
collapsed: () => controls.todoCollapsed,
|
||||
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
||||
},
|
||||
followup: () => undefined,
|
||||
revert: () => undefined,
|
||||
onResponseSubmit: () => {},
|
||||
openParent: () => {},
|
||||
setPromptRef: () => {},
|
||||
setDockRef: () => {},
|
||||
})}
|
||||
promptInput={
|
||||
<PromptInput
|
||||
controls={inputControls}
|
||||
{...input}
|
||||
ref={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/PromptInput",
|
||||
id: "app-prompt-input",
|
||||
@@ -212,12 +123,3 @@ export const Basic = {
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const DockAlreadyOpen = {
|
||||
render: () => (
|
||||
<div class="pt-10">
|
||||
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
|
||||
<PromptInputWithOpenDock />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -11,18 +11,6 @@ type SessionCreateInput = {
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
location?: { directory: string }
|
||||
}
|
||||
const admitted: Array<{
|
||||
directory?: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
text: string
|
||||
displayText: string
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string }
|
||||
comments: unknown[]
|
||||
}> = []
|
||||
const confirmed: unknown[] = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||
const sentShellDirectories: string[] = []
|
||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||
@@ -36,12 +24,9 @@ const switchedModels: Array<{
|
||||
}> = []
|
||||
const sessionRequestOrder: string[] = []
|
||||
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||
const syncedServers: string[] = []
|
||||
const admittedServers: string[] = []
|
||||
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||
let serverSessionSyncs = 0
|
||||
let restoredPrompts = 0
|
||||
let clearEchoCalls = 0
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
@@ -52,11 +37,8 @@ let createWorktreeGate: Promise<void> | undefined
|
||||
let worktreeFailure: Error | undefined
|
||||
let locationFailure: Error | undefined
|
||||
let promptFailure: Error | undefined
|
||||
let clearEchoResult = true
|
||||
let worktreeCreates = 0
|
||||
let activeSDK = "server-a"
|
||||
let activeServerSync = "server-a"
|
||||
let activeDirectorySync = "server-a"
|
||||
let commands: Array<{ name: string }> = []
|
||||
let worktreeDirectory = "/repo/new-0"
|
||||
let worktreeID = 0
|
||||
@@ -233,83 +215,34 @@ beforeAll(async () => {
|
||||
usePrompt: () => prompt,
|
||||
}))
|
||||
|
||||
mock.module("@/context/sdk", () => ({
|
||||
useSDK: () => {
|
||||
mock.module("@/context/location", () => ({
|
||||
useWorkspaceLocation: () => {
|
||||
return () => ({
|
||||
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
||||
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
|
||||
api: rootClient.api,
|
||||
url: "http://localhost:4096",
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => () => {
|
||||
const server = activeDirectorySync
|
||||
return {
|
||||
data: { command: commands, project: "project" },
|
||||
session: {
|
||||
inbox: {
|
||||
echo: (value: {
|
||||
directory?: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
text: string
|
||||
displayText: string
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string }
|
||||
comments: unknown[]
|
||||
}) => {
|
||||
admittedServers.push(server)
|
||||
admitted.push(value)
|
||||
},
|
||||
confirm: (value: unknown) => {
|
||||
confirmed.push(value)
|
||||
},
|
||||
clearEcho: () => {
|
||||
clearEchoCalls++
|
||||
return clearEchoResult
|
||||
},
|
||||
},
|
||||
},
|
||||
set: () => undefined,
|
||||
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
|
||||
}
|
||||
},
|
||||
mock.module("@/context/server-sdk", () => ({
|
||||
useServerSDK: () => ({
|
||||
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
||||
api: rootClient.api,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/server-sync", () => ({
|
||||
useServerSync: () => {
|
||||
const server = activeServerSync
|
||||
return {
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
mock.module("@/context/server", () => ({
|
||||
useData: () => ({
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
setStatus: () => undefined,
|
||||
},
|
||||
location: {
|
||||
info: () => ({ project: { id: "project", directory: "/repo/main" } }),
|
||||
command: {
|
||||
list: () => commands,
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedServers.push(server)
|
||||
storedSessions[directory] ??= []
|
||||
return [
|
||||
{ session: storedSessions[directory] },
|
||||
(...args: unknown[]) => {
|
||||
if (args[0] !== "session") return
|
||||
const next = args[1]
|
||||
if (typeof next === "function") {
|
||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||
return
|
||||
}
|
||||
if (Array.isArray(next)) {
|
||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/platform", () => ({
|
||||
@@ -330,8 +263,6 @@ beforeAll(async () => {
|
||||
|
||||
beforeEach(() => {
|
||||
createdSessions.length = 0
|
||||
admitted.length = 0
|
||||
confirmed.length = 0
|
||||
promotedDrafts.length = 0
|
||||
updatedDrafts.length = 0
|
||||
sentCommands.length = 0
|
||||
@@ -340,11 +271,8 @@ beforeEach(() => {
|
||||
switchedAgents.length = 0
|
||||
switchedModels.length = 0
|
||||
sessionRequestOrder.length = 0
|
||||
syncedServers.length = 0
|
||||
admittedServers.length = 0
|
||||
promptCaptures.length = 0
|
||||
restoredPrompts = 0
|
||||
clearEchoCalls = 0
|
||||
params = {}
|
||||
search = {}
|
||||
sentShell.length = 0
|
||||
@@ -352,8 +280,6 @@ beforeEach(() => {
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
activeSDK = "server-a"
|
||||
activeServerSync = "server-a"
|
||||
activeDirectorySync = "server-a"
|
||||
commands = []
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
worktreeDirectory = `/repo/new-${++worktreeID}`
|
||||
@@ -363,11 +289,9 @@ beforeEach(() => {
|
||||
worktreeFailure = undefined
|
||||
locationFailure = undefined
|
||||
promptFailure = undefined
|
||||
clearEchoResult = true
|
||||
worktreeCreates = 0
|
||||
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
@@ -442,8 +366,6 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
const result = submit.handleSubmit(event)
|
||||
activeSDK = "server-b"
|
||||
activeServerSync = "server-b"
|
||||
activeDirectorySync = "server-b"
|
||||
search.draftId = "draft-2"
|
||||
release()
|
||||
await result
|
||||
@@ -451,8 +373,6 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
||||
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
||||
expect(admittedServers).toEqual(["server-a"])
|
||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||
expect(submitted).toBe(0)
|
||||
})
|
||||
@@ -472,15 +392,6 @@ describe("prompt submit worktree selection", () => {
|
||||
await submit.handleSubmit(event)
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(admitted).toHaveLength(1)
|
||||
expect(admitted[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
text: "ls",
|
||||
agent: "agent",
|
||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||
})
|
||||
expect(admitted[0]?.messageID).toStartWith("msg_")
|
||||
expect(confirmed).toMatchObject([{ id: admitted[0]?.messageID, sessionID: "session-1" }])
|
||||
expect(sentPrompts).toEqual(["/repo/main"])
|
||||
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
|
||||
expect(switchedModels).toEqual([
|
||||
@@ -499,10 +410,9 @@ describe("prompt submit worktree selection", () => {
|
||||
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||
})
|
||||
|
||||
test("keeps a confirmed echo when the prompt response is lost", async () => {
|
||||
test("restores the prompt when sending fails", async () => {
|
||||
params = { id: "session-1" }
|
||||
promptFailure = new Error("connection lost")
|
||||
clearEchoResult = false
|
||||
const submit = makeSubmit({
|
||||
info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }),
|
||||
})
|
||||
@@ -510,9 +420,7 @@ describe("prompt submit worktree selection", () => {
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(admitted).toHaveLength(1)
|
||||
expect(clearEchoCalls).toBe(1)
|
||||
expect(restoredPrompts).toBe(0)
|
||||
expect(restoredPrompts).toBe(1)
|
||||
})
|
||||
|
||||
test("submits slash commands through the current session API", async () => {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { startTransition, type Accessor } from "solid-js"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLocal, type ModelSelection } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||
import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||
import { buildPromptRequest } from "./build-prompt-request"
|
||||
@@ -35,9 +34,8 @@ export type FollowupDraft = {
|
||||
}
|
||||
|
||||
type FollowupSendInput = {
|
||||
api: DirectorySDK["api"]["session"]
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
api: ServerSDK["api"]["session"]
|
||||
data: Data
|
||||
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||
draft: FollowupDraft
|
||||
messageID?: string
|
||||
@@ -53,17 +51,17 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const setBusy = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
|
||||
input.data.session.setStatus(input.draft.sessionID, "running")
|
||||
}
|
||||
|
||||
const setIdle = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
|
||||
input.data.session.setStatus(input.draft.sessionID, "idle")
|
||||
}
|
||||
|
||||
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 && input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)) {
|
||||
setBusy()
|
||||
try {
|
||||
const messageID = Identifier.ascending("message")
|
||||
@@ -108,14 +106,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
})
|
||||
|
||||
setBusy()
|
||||
input.sync.session.inbox.echo({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
agent: input.draft.agent,
|
||||
model: { ...input.draft.model, variant: input.draft.variant },
|
||||
...request,
|
||||
})
|
||||
|
||||
try {
|
||||
const session = input.session()
|
||||
@@ -137,22 +127,15 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
})
|
||||
}
|
||||
|
||||
const admitted = await input.api.prompt({
|
||||
await input.api.prompt({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
text: request.text,
|
||||
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||
agents: request.agents,
|
||||
})
|
||||
input.sync.session.inbox.confirm(admitted)
|
||||
return true
|
||||
} catch (err) {
|
||||
const failed = input.sync.session.inbox.clearEcho({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
})
|
||||
if (!failed) return true
|
||||
setIdle()
|
||||
throw err
|
||||
}
|
||||
@@ -186,9 +169,9 @@ type PromptSubmitInput = {
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const prompt = input.prompt
|
||||
@@ -209,12 +192,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
serverSync.session.set("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
||||
return sdk()
|
||||
.api.session.interrupt({ sessionID })
|
||||
return serverSDK.api.session.interrupt({ sessionID })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
@@ -241,21 +221,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
|
||||
target.session.remember(info)
|
||||
const [, setStore] = target.child(dir)
|
||||
setStore("session", (list: SessionInfo[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
if (result.found) {
|
||||
next[result.index] = info
|
||||
return next
|
||||
}
|
||||
next.splice(result.index, 0, info)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -288,9 +253,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
const submissionSDK = sdk()
|
||||
const submissionSync = sync()
|
||||
const submissionServerSync = serverSync
|
||||
const submissionScope = submissionSDK.scope
|
||||
const submissionServerSDK = serverSDK
|
||||
const submissionData = data
|
||||
const submissionScope = submissionServerSDK.scope
|
||||
const projectDirectory = submissionSDK.directory
|
||||
const sessionID = params.id
|
||||
const isNewSession = !sessionID
|
||||
@@ -318,14 +283,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
let sessionDirectory = projectDirectory
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await submissionSDK.api.worktree
|
||||
const createdWorktree = await submissionServerSDK.api.worktree
|
||||
.create({
|
||||
projectID: submissionSync.data.project,
|
||||
projectID: submissionData.location.info({ directory: projectDirectory })?.project.id ?? "",
|
||||
strategy: "git",
|
||||
directory: getDirectory(submissionSync.project?.worktree ?? projectDirectory),
|
||||
directory: getDirectory(
|
||||
submissionData.location.info({ directory: projectDirectory })?.project.directory ?? projectDirectory,
|
||||
),
|
||||
})
|
||||
.then(async (created) => {
|
||||
await submissionSDK.api.location.get({ location: { directory: created.directory } })
|
||||
await submissionServerSDK.api.location.get({ location: { directory: created.directory } })
|
||||
return created
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -342,15 +309,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
submissionServerSync.child(sessionDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
let session = currentSession
|
||||
if (!session && isNewSession) {
|
||||
const created = await submissionSDK.api.session
|
||||
const created = await submissionServerSDK.api.session
|
||||
.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
@@ -364,7 +327,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return undefined
|
||||
})
|
||||
if (created) {
|
||||
seed(submissionServerSync, sessionDirectory, created)
|
||||
submissionData.session.remember(created)
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
@@ -445,7 +408,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
void submissionSDK.api.session
|
||||
void submissionServerSDK.api.session
|
||||
.shell({
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
@@ -464,12 +427,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
||||
const customCommand = submissionData.location.command
|
||||
.list({ directory: sessionDirectory })
|
||||
?.find((command) => command.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
||||
void submissionSDK.api.session
|
||||
submissionData.session.setStatus(session.id, "running")
|
||||
void submissionServerSDK.api.session
|
||||
.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
@@ -485,7 +450,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
),
|
||||
})
|
||||
.catch((err) => {
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||
submissionData.session.setStatus(session.id, "idle")
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
@@ -503,16 +468,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
clearInput()
|
||||
|
||||
void sendFollowupDraft({
|
||||
api: submissionSDK.api.session,
|
||||
sync: submissionSync,
|
||||
serverSync: submissionServerSync,
|
||||
api: submissionServerSDK.api.session,
|
||||
data: submissionData,
|
||||
session: () => session,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
}).catch((err) => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||
submissionData.session.setStatus(session.id, "idle")
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||
import { useQueryClient } from "@tanstack/solid-query"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useData } from "@/context/server"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
|
||||
@@ -18,8 +16,7 @@ export function createProviderConnectionController(options: {
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const queryClient = useQueryClient()
|
||||
const data = useData()
|
||||
const location = () => {
|
||||
const directory = options.directory()
|
||||
return directory ? { directory } : undefined
|
||||
@@ -118,12 +115,15 @@ export function createProviderConnectionController(options: {
|
||||
}
|
||||
const finish = async () => {
|
||||
cancelPolling()
|
||||
const directory = options.directory()
|
||||
const key = directory ? pathKey(directory) : null
|
||||
const ref = location()
|
||||
data.location.integration.invalidate(ref)
|
||||
data.location.provider.invalidate(ref)
|
||||
data.location.model.invalidate(ref)
|
||||
await Promise.all([
|
||||
queryClient.refetchQueries(serverSync.queryOptions.providers(key)).catch(() => undefined),
|
||||
queryClient.refetchQueries(serverSync.queryOptions.integrations(key)).catch(() => undefined),
|
||||
])
|
||||
data.location.integration.sync(ref),
|
||||
data.location.provider.sync(ref),
|
||||
data.location.model.sync(ref),
|
||||
]).catch(() => undefined)
|
||||
if (polling.disposed) return
|
||||
options.onComplete()
|
||||
}
|
||||
|
||||
@@ -7,11 +7,10 @@ import { createMediaQuery } from "@solid-primitives/media"
|
||||
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { getSessionContext } from "@/components/session/session-context-metrics"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
|
||||
@@ -40,11 +39,11 @@ function openSessionContext(args: {
|
||||
}
|
||||
|
||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const file = useFile()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
@@ -56,8 +55,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
fileBrowser: () => isDesktop() && !!params.id,
|
||||
})
|
||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
const messages = createMemo(() => (params.id ? data.session.message.list(params.id) : []))
|
||||
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
@@ -67,7 +66,21 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}),
|
||||
)
|
||||
|
||||
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const context = createMemo(() => {
|
||||
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
|
||||
if (message?.type !== "assistant" || !message.tokens) return
|
||||
const model = providers.all().get(message.model.providerID)?.models[message.model.id]
|
||||
const total =
|
||||
message.tokens.input +
|
||||
message.tokens.output +
|
||||
message.tokens.reasoning +
|
||||
message.tokens.cache.read +
|
||||
message.tokens.cache.write
|
||||
return {
|
||||
total,
|
||||
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
||||
}
|
||||
})
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(info()?.cost ?? 0)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js
|
||||
import type { Project } from "@/types"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { showToast } from "@/utils/toast"
|
||||
@@ -26,11 +26,11 @@ export function SessionWorkspaceMenu(props: {
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
|
||||
const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID)
|
||||
const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
|
||||
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
|
||||
const workspaces = () =>
|
||||
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { findLast } from "@opencode-ai/core/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -11,25 +10,16 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@/types"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContext } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
import { createSessionContextFormatter } from "./session-context-format"
|
||||
|
||||
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
|
||||
system: "var(--syntax-info)",
|
||||
user: "var(--syntax-success)",
|
||||
assistant: "var(--syntax-property)",
|
||||
tool: "var(--syntax-warning)",
|
||||
other: "var(--syntax-comment)",
|
||||
}
|
||||
|
||||
function Stat(props: { label: string; value: JSX.Element }) {
|
||||
return (
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -39,12 +29,11 @@ function Stat(props: { label: string; value: JSX.Element }) {
|
||||
)
|
||||
}
|
||||
|
||||
function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
|
||||
function RawMessageContent(props: { message: SessionMessageInfo; onRendered: () => void }) {
|
||||
const file = createMemo(() => {
|
||||
const parts = props.getParts(props.message.id)
|
||||
const contents = JSON.stringify({ message: props.message, parts }, null, 2)
|
||||
const contents = JSON.stringify(props.message, null, 2)
|
||||
return {
|
||||
name: `${props.message.role}-${props.message.id}.json`,
|
||||
name: `${props.message.type}-${props.message.id}.json`,
|
||||
contents,
|
||||
cacheKey: checksum(contents),
|
||||
}
|
||||
@@ -62,8 +51,7 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) =>
|
||||
}
|
||||
|
||||
function RawMessage(props: {
|
||||
message: Message
|
||||
getParts: (id: string) => Part[]
|
||||
message: SessionMessageInfo
|
||||
onRendered: () => void
|
||||
time: (value: number | undefined) => string
|
||||
}) {
|
||||
@@ -73,7 +61,7 @@ function RawMessage(props: {
|
||||
<Accordion.Trigger>
|
||||
<div class="flex items-center justify-between gap-2 w-full">
|
||||
<div class="min-w-0 truncate">
|
||||
{props.message.role} <span class="text-text-base">• {props.message.id}</span>
|
||||
{props.message.type} <span class="text-text-base">• {props.message.id}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
|
||||
@@ -84,52 +72,35 @@ function RawMessage(props: {
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content class="bg-background-base">
|
||||
<div class="p-3">
|
||||
<RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} />
|
||||
<RawMessageContent message={props.message} onRendered={props.onRendered} />
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMessages: Message[] = []
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const emptyMessages: SessionMessageInfo[] = []
|
||||
|
||||
export function SessionContextTab() {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
|
||||
|
||||
const messages = createMemo(
|
||||
() => {
|
||||
const id = params.id
|
||||
if (!id) return emptyMessages
|
||||
return (sync().data.message[id] ?? []) as Message[]
|
||||
return data.session.message.list(id)
|
||||
},
|
||||
emptyMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
const visibleUserMessages = createMemo(
|
||||
() => {
|
||||
const revert = info()?.revert?.messageID
|
||||
if (!revert) return userMessages()
|
||||
const boundary = userMessages().findIndex((message) => message.id === revert)
|
||||
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat(language.intl(), {
|
||||
@@ -138,7 +109,28 @@ export function SessionContextTab() {
|
||||
}),
|
||||
)
|
||||
|
||||
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const ctx = createMemo(() => {
|
||||
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
|
||||
if (message?.type !== "assistant" || !message.tokens) return
|
||||
const provider = providers.all().get(message.model.providerID)
|
||||
const model = provider?.models[message.model.id]
|
||||
const total =
|
||||
message.tokens.input +
|
||||
message.tokens.output +
|
||||
message.tokens.reasoning +
|
||||
message.tokens.cache.read +
|
||||
message.tokens.cache.write
|
||||
return {
|
||||
message,
|
||||
tokens: message.tokens,
|
||||
providerLabel: provider?.name ?? message.model.providerID,
|
||||
modelLabel: model?.name ?? message.model.id,
|
||||
limit: model?.limit.context,
|
||||
input: message.tokens.input,
|
||||
total,
|
||||
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
||||
}
|
||||
})
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
const cost = createMemo(() => {
|
||||
@@ -147,8 +139,8 @@ export function SessionContextTab() {
|
||||
|
||||
const counts = createMemo(() => {
|
||||
const all = messages()
|
||||
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
|
||||
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
|
||||
const user = all.reduce((count, message) => count + (message.type === "user" ? 1 : 0), 0)
|
||||
const assistant = all.reduce((count, message) => count + (message.type === "assistant" ? 1 : 0), 0)
|
||||
return {
|
||||
all: all.length,
|
||||
user,
|
||||
@@ -157,8 +149,7 @@ export function SessionContextTab() {
|
||||
})
|
||||
|
||||
const systemPrompt = createMemo(() => {
|
||||
const msg = findLast(visibleUserMessages(), (m) => !!m.system)
|
||||
const system = msg?.system
|
||||
const system = messages().findLast((message) => message.type === "system")?.text
|
||||
if (!system) return
|
||||
const trimmed = system.trim()
|
||||
if (!trimmed) return
|
||||
@@ -177,30 +168,6 @@ export function SessionContextTab() {
|
||||
return c.modelLabel
|
||||
})
|
||||
|
||||
const breakdown = createMemo(
|
||||
on(
|
||||
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
|
||||
() => {
|
||||
const c = ctx()
|
||||
if (!c?.input) return []
|
||||
return estimateSessionContextBreakdown({
|
||||
messages: messages(),
|
||||
parts: sync().data.part as Record<string, Part[] | undefined>,
|
||||
input: c.input,
|
||||
systemPrompt: systemPrompt(),
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const breakdownLabel = (key: SessionContextBreakdownKey) => {
|
||||
if (key === "system") return language.t("context.breakdown.system")
|
||||
if (key === "user") return language.t("context.breakdown.user")
|
||||
if (key === "assistant") return language.t("context.breakdown.assistant")
|
||||
if (key === "tool") return language.t("context.breakdown.tool")
|
||||
return language.t("context.breakdown.other")
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
|
||||
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) },
|
||||
@@ -210,12 +177,12 @@ export function SessionContextTab() {
|
||||
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
|
||||
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.tokens.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.tokens.reasoning) },
|
||||
{
|
||||
label: "context.stats.cacheTokens",
|
||||
value: () =>
|
||||
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
|
||||
`${formatter().number(ctx()?.tokens.cache.read)} / ${formatter().number(ctx()?.tokens.cache.write)}`,
|
||||
},
|
||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||
@@ -230,7 +197,7 @@ export function SessionContextTab() {
|
||||
try {
|
||||
const data = await fetchSessionExport({
|
||||
sessionID,
|
||||
api: sdk().api,
|
||||
api: serverSDK.api,
|
||||
})
|
||||
const filename = sessionExportFilename(data.info)
|
||||
downloadSessionExport(filename, data)
|
||||
@@ -252,8 +219,6 @@ export function SessionContextTab() {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[]
|
||||
|
||||
const restoreScroll = () => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
@@ -314,37 +279,6 @@ export function SessionContextTab() {
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={breakdown().length > 0}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-12-regular text-text-weak">{language.t("context.breakdown.title")}</div>
|
||||
<div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
|
||||
<For each={breakdown()}>
|
||||
{(segment) => (
|
||||
<div
|
||||
class="h-full"
|
||||
style={{
|
||||
width: `${segment.width}%`,
|
||||
"background-color": BREAKDOWN_COLOR[segment.key],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<For each={breakdown()}>
|
||||
{(segment) => (
|
||||
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
||||
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
|
||||
<div>{breakdownLabel(segment.key)}</div>
|
||||
<div class="text-text-weaker">{segment.percent.toLocaleString(language.intl())}%</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="hidden text-11-regular text-text-weaker">{language.t("context.breakdown.note")}</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={systemPrompt()}>
|
||||
{(prompt) => (
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -372,7 +306,7 @@ export function SessionContextTab() {
|
||||
<Accordion multiple>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
|
||||
<RawMessage message={message} onRendered={restoreScroll} time={formatter().time} />
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useData } from "@/context/server"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
@@ -16,28 +16,32 @@ interface NewSessionViewProps {
|
||||
}
|
||||
|
||||
export function NewSessionView(props: NewSessionViewProps) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
|
||||
const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
|
||||
const project = createMemo(() => {
|
||||
const projectID = data.location.info({ directory: sdk().directory })?.project.id
|
||||
return projectID ? data.project.get(projectID) : undefined
|
||||
})
|
||||
const sandboxes = createMemo(() => project()?.sandboxes ?? [])
|
||||
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
|
||||
const current = createMemo(() => {
|
||||
const selection = props.worktree
|
||||
if (options().includes(selection)) return selection
|
||||
return MAIN_WORKTREE
|
||||
})
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
const projectRoot = createMemo(() => project()?.canonical ?? sdk().directory)
|
||||
const isWorktree = createMemo(() => {
|
||||
const project = sync().project
|
||||
if (!project) return false
|
||||
return sdk().directory !== project.worktree
|
||||
const current = project()
|
||||
if (!current) return false
|
||||
return sdk().directory !== current.canonical
|
||||
})
|
||||
|
||||
const label = (value: string) => {
|
||||
if (value === MAIN_WORKTREE) {
|
||||
if (isWorktree()) return language.t("session.new.worktree.main")
|
||||
const branch = sync().data.vcs?.branch
|
||||
const branch = data.location.vcs.info({ directory: sdk().directory })?.branch.current
|
||||
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
|
||||
return language.t("session.new.worktree.main")
|
||||
}
|
||||
@@ -69,7 +73,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
{label(current())}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sync().project}>
|
||||
<Show when={project()}>
|
||||
{(project) => (
|
||||
<div class="flex items-start justify-center gap-3 min-h-5">
|
||||
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
|
||||
|
||||
@@ -63,7 +63,7 @@ export const DialogSettings: Component<{
|
||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
return draft?.type === "draft" ? draft.directory : undefined
|
||||
}
|
||||
if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory
|
||||
if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory
|
||||
return undefined
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Component, For, createMemo, createResource } from "solid-js"
|
||||
import { Component, For, createEffect, createMemo, createResource } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import "./settings-v2.css"
|
||||
@@ -21,35 +22,35 @@ interface PluginRowItem {
|
||||
export const SettingsExtensionsV2: Component = () => {
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const [mcpList, { refetch: refetchMcp }] = createResource(
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() => serverSdk.api.mcp.list().then((result) => result.data),
|
||||
)
|
||||
const toggleMcp = useMcpToggle(() => undefined, refetchMcp)
|
||||
const mcps = createMemo<McpRowItem[]>(() => {
|
||||
const configMcp = serverSync.data.config.mcp ?? {}
|
||||
return Object.entries(configMcp).map(([name, config]) => ({
|
||||
name,
|
||||
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
|
||||
return (mcpList.latest ?? []).map((server) => ({
|
||||
name: server.name,
|
||||
enabled: server.status.status === "connected",
|
||||
}))
|
||||
})
|
||||
|
||||
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
|
||||
const before = serverSync.data.config.mcp ?? {}
|
||||
const config = before[item.name]
|
||||
if (typeof config !== "object" || config === null) return
|
||||
const next = { ...before, [item.name]: { ...config, enabled: checked } }
|
||||
serverSync.set("config", "mcp", next)
|
||||
void serverSync.updateConfig({ mcp: next }).catch(() => serverSync.set("config", "mcp", before))
|
||||
if (item.enabled === checked || toggleMcp.isPending) return
|
||||
toggleMcp.mutate(item.name)
|
||||
}
|
||||
|
||||
const plugins = createMemo<PluginRowItem[]>(() => {
|
||||
const raw = serverSync.data.config.plugin ?? []
|
||||
return raw.map((item) => {
|
||||
const name = typeof item === "string" ? item : item[0]
|
||||
return { name }
|
||||
})
|
||||
})
|
||||
const [pluginList] = createResource(
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() => serverSdk.api.plugin.list().then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
|
||||
|
||||
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
|
||||
initialValue: [],
|
||||
createEffect(() => {
|
||||
if (serverSdk.connection.status() !== "connected") return
|
||||
void data.location.skill.sync().catch(() => undefined)
|
||||
})
|
||||
const skills = () => data.location.skill.list() ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createMemo, createResource, onMount, type Accessor } from "solid-js"
|
||||
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
@@ -34,7 +33,7 @@ export function createPermissionScopeController(
|
||||
const s = server()
|
||||
const id = sessionID()
|
||||
if (!s || !id) return undefined
|
||||
return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory
|
||||
return serverCtx()?.data.session.get(id)?.location.directory
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useIntegrations } from "@/hooks/use-integrations"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||
import { SettingsServerScope } from "../settings-server-picker"
|
||||
@@ -39,7 +38,6 @@ export const SettingsProvidersV2: Component<{
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(() => props.directory)
|
||||
const integrations = useIntegrations(() => props.directory)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
@@ -55,15 +53,12 @@ export const SettingsProvidersV2: Component<{
|
||||
}
|
||||
|
||||
const connected = createMemo(() => {
|
||||
return providers.connected().filter(
|
||||
(provider) =>
|
||||
provider.id !== "opencode" ||
|
||||
Object.values(provider.models).some((model) => {
|
||||
if (typeof model !== "object" || model === null || !("cost" in model)) return false
|
||||
const cost = model.cost
|
||||
return typeof cost === "object" && cost !== null && "input" in cost
|
||||
}),
|
||||
)
|
||||
return providers
|
||||
.connected()
|
||||
.filter(
|
||||
(provider) =>
|
||||
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost.input > 0),
|
||||
)
|
||||
})
|
||||
|
||||
const popular = createMemo(() => {
|
||||
@@ -94,10 +89,7 @@ export const SettingsProvidersV2: Component<{
|
||||
const current = source(item)
|
||||
if (current === "env") return language.t("settings.providers.tag.environment")
|
||||
if (current === "api") return language.t("provider.connect.method.apiKey")
|
||||
if (current === "config") {
|
||||
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.config")
|
||||
}
|
||||
if (current === "config") return language.t("settings.providers.tag.config")
|
||||
if (current === "custom") return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
@@ -105,19 +97,12 @@ export const SettingsProvidersV2: Component<{
|
||||
const canDisconnect = (item: ProviderItem) => {
|
||||
const current = integration(item.id)
|
||||
if (current) return current.connections.some((connection) => connection.type === "credential")
|
||||
return source(item) !== "env" && !isConfigCustom(item.id)
|
||||
const currentSource = source(item)
|
||||
return currentSource !== "env" && currentSource !== "config"
|
||||
}
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const isConfigCustom = (providerID: string) => {
|
||||
const provider = serverSync.data.config.provider?.[providerID]
|
||||
if (!provider) return false
|
||||
if (provider.npm !== "@ai-sdk/openai-compatible") return false
|
||||
if (!provider.models || Object.keys(provider.models).length === 0) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
const location = props.directory ? { directory: props.directory } : undefined
|
||||
await serverSdk.api.integration
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Component } from "solid-js"
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
@@ -13,9 +13,10 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
import { sessionLabel } from "@/utils/session-title"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
} from "@/utils/workspace"
|
||||
import { listAllSessions } from "@/utils/session"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||
import "./settings-v2.css"
|
||||
|
||||
type Workspace = {
|
||||
@@ -48,7 +50,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const [store, setStore] = createStore({
|
||||
@@ -56,9 +58,22 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
||||
transaction: undefined as "confirm" | "running" | undefined,
|
||||
})
|
||||
|
||||
const workspaces = createMemo(() => workspaceInventory(serverSync.data.project))
|
||||
const projectQuery = useQuery(() => ({
|
||||
queryKey: [serverSDK.scope, "settings-workspace-projects"] as const,
|
||||
queryFn: async () =>
|
||||
Promise.all(
|
||||
(await serverSDK.api.project.list()).map(async (project) => {
|
||||
const worktrees = await serverSDK.api.worktree
|
||||
.list({ projectID: project.id })
|
||||
.catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))])
|
||||
return normalizeProjectInfo({ ...project, worktrees })
|
||||
}),
|
||||
),
|
||||
refetchOnMount: "always",
|
||||
}))
|
||||
const workspaces = createMemo(() => workspaceInventory(projectQuery.data ?? []))
|
||||
const projects = createMemo(() =>
|
||||
serverSync.data.project.filter((project) => managedWorkspaceDirectories(project).length > 0),
|
||||
(projectQuery.data ?? []).filter((project) => managedWorkspaceDirectories(project).length > 0),
|
||||
)
|
||||
const projectName = (project: Project) => project.name || getFilename(project.worktree)
|
||||
const projectOptions = createMemo(() => [
|
||||
@@ -71,18 +86,21 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
||||
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
||||
const captureDeleteContext = () => {
|
||||
const sdk = serverSDK
|
||||
return { sdk, sync: serverSync, server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
|
||||
return {
|
||||
sdk,
|
||||
data,
|
||||
server: ServerConnection.key(sdk.server),
|
||||
activeDirectory: props.activeDirectory,
|
||||
}
|
||||
}
|
||||
const loadSessions = async (context = captureDeleteContext()) => {
|
||||
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
|
||||
return mergeWorkspaceSessionInventory(
|
||||
fetched,
|
||||
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
|
||||
)
|
||||
fetched.forEach(context.data.session.remember)
|
||||
return mergeWorkspaceSessionInventory(fetched, context.data.session.list())
|
||||
}
|
||||
const sessionQuery = useQuery(() => ({
|
||||
queryKey: [serverSDK.scope, null, "settings-workspace-sessions"] as const,
|
||||
queryFn: () => loadSessions(),
|
||||
queryFn: () => loadSessions().then(() => Date.now()),
|
||||
refetchOnMount: "always",
|
||||
}))
|
||||
const sessionsByWorkspace = createMemo(
|
||||
@@ -90,7 +108,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
||||
new Map(
|
||||
workspaces().map((workspace) => [
|
||||
pathKey(workspace.directory),
|
||||
sessionQuery.isSuccess ? sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory) : [],
|
||||
sessionQuery.isSuccess ? sessionsForWorkspace(data.session.list(), workspace.directory) : [],
|
||||
]),
|
||||
),
|
||||
)
|
||||
@@ -182,19 +200,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
||||
platform,
|
||||
context.sdk.scope,
|
||||
)
|
||||
context.sync.set(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
const project = draft.find((item) => item.id === workspace.project.id)
|
||||
if (!project) return
|
||||
project.sandboxes = (project.sandboxes ?? []).filter(
|
||||
(directory) => pathKey(directory) !== pathKey(workspace.directory),
|
||||
)
|
||||
project.worktrees = project.worktrees.filter(
|
||||
(worktree) => pathKey(worktree.directory) !== pathKey(workspace.directory),
|
||||
)
|
||||
}),
|
||||
)
|
||||
await projectQuery.refetch()
|
||||
}
|
||||
|
||||
let inspectionID = 0
|
||||
@@ -382,7 +388,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
||||
<For each={linked()}>
|
||||
{(session) => (
|
||||
<div class="settings-v2-workspaces-session">
|
||||
<span>{session.title}</span>
|
||||
<span>{sessionLabel(session)}</span>
|
||||
<Show when={sessionTime(session)}>
|
||||
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
|
||||
</Show>
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, createResource, For, type JSXElement, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection, useServers } from "@/context/servers"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -25,110 +19,22 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
)
|
||||
}
|
||||
|
||||
const listServersByHealth = (
|
||||
list: ServerConnection.Any[],
|
||||
active: ServerConnection.Key | undefined,
|
||||
status: Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
) => {
|
||||
if (!list.length) return list
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
return list.slice().sort((a, b) => {
|
||||
if (ServerConnection.key(a) === active) return -1
|
||||
if (ServerConnection.key(b) === active) return 1
|
||||
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
const useDefaultServerKey = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||
) => {
|
||||
const [state, setState] = createStore({
|
||||
key: undefined as ServerConnection.Key | undefined,
|
||||
tick: 0,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
state.tick
|
||||
let dead = false
|
||||
const result = get?.()
|
||||
if (!result) {
|
||||
setState("key", undefined)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result instanceof Promise) {
|
||||
void result.then((next) => {
|
||||
if (dead) return
|
||||
setState("key", next ?? undefined)
|
||||
})
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setState("key", ServerConnection.Key.make(result))
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
key: () => {
|
||||
return state.key
|
||||
},
|
||||
refresh: () => setState("tick", (value) => value + 1),
|
||||
}
|
||||
}
|
||||
|
||||
type ServerStatusItem = {
|
||||
key: ServerConnection.Key
|
||||
conn: ServerConnection.Any
|
||||
health?: ServerHealth
|
||||
blocked: boolean
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const data = useData()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
let dialogRun = 0
|
||||
onCleanup(() => {
|
||||
dialogRun += 1
|
||||
})
|
||||
const toggleMcp = useMcpToggle()
|
||||
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
|
||||
const toggleMcp = useMcpToggle(() => sdk().directory)
|
||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
|
||||
const mcpNames = createMemo(() => mcp().map((server) => server.name).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const [pluginList] = createResource(
|
||||
() => (props.shown ? sdk().directory : undefined),
|
||||
(directory) =>
|
||||
sdk()
|
||||
.api.plugin.list({ location: { directory } })
|
||||
serverSDK.api.plugin
|
||||
.list({ location: { directory } })
|
||||
.then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
|
||||
@@ -150,10 +56,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||
{language.t("status.popover.tab.mcp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||
{language.t("status.popover.tab.lsp")}
|
||||
</Tabs.Trigger>
|
||||
{/* TODO: Restore LSP status when V2 exposes it. */}
|
||||
<Show when={true}>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
@@ -223,34 +126,6 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="lsp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={lspItems().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.lsp.empty")}</div>
|
||||
}
|
||||
>
|
||||
<For each={lspItems()}>
|
||||
{(item) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": item.status === "connected",
|
||||
"bg-icon-critical-base": item.status === "error",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Show when={true}>
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
|
||||
@@ -5,16 +5,14 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection, useServers } from "@/context/servers"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import {
|
||||
hasNonBlockingServiceIssue,
|
||||
hasServiceNeedingAttention,
|
||||
serverStatusDotClass,
|
||||
} from "./status-popover-indicator"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useData, useServer } from "@/context/server"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
|
||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
|
||||
@@ -22,19 +20,21 @@ export function StatusPopover() {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
|
||||
const ready = createMemo(() => serverHealth() === false || mcp() !== undefined)
|
||||
const attention = createMemo(() =>
|
||||
hasServiceNeedingAttention({
|
||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||
mcp: (mcp() ?? []).map((item) => item.status.status),
|
||||
}),
|
||||
)
|
||||
const issue = createMemo(() =>
|
||||
hasNonBlockingServiceIssue({
|
||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||
mcp: (mcp() ?? []).map((item) => item.status.status),
|
||||
lsp: [],
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -86,19 +86,21 @@ export function StatusPopoverV2() {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
|
||||
const ready = createMemo(() => serverHealth() === false || mcp() !== undefined)
|
||||
const attention = createMemo(() =>
|
||||
hasServiceNeedingAttention({
|
||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||
mcp: (mcp() ?? []).map((item) => item.status.status),
|
||||
}),
|
||||
)
|
||||
const issue = createMemo(() =>
|
||||
hasNonBlockingServiceIssue({
|
||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||
mcp: (mcp() ?? []).map((item) => item.status.status),
|
||||
lsp: [],
|
||||
}),
|
||||
)
|
||||
const state = createMemo<StatusPopoverState>(() => ({
|
||||
|
||||
@@ -10,7 +10,8 @@ import { SerializeAddon } from "@/addons/serialize"
|
||||
import { matchKeybind, parseKeybind } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||
import type { LocalPTY } from "@/context/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
@@ -171,13 +172,14 @@ const persistTerminal = (input: {
|
||||
|
||||
export const Terminal = (props: TerminalProps) => {
|
||||
const platform = usePlatform()
|
||||
const sdk = useSDK()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
// Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes.
|
||||
const directory = sdk().directory
|
||||
const url = sdk().url
|
||||
const url = serverSDK.url
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, [
|
||||
"pty",
|
||||
@@ -235,8 +237,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const pushSize = async (cols: number, rows: number) => {
|
||||
return sdk()
|
||||
.api.pty.update({
|
||||
return serverSDK.api.pty
|
||||
.update({
|
||||
ptyID: id,
|
||||
location: { directory },
|
||||
size: { cols, rows },
|
||||
@@ -519,8 +521,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
const gone = async () => {
|
||||
return sdk()
|
||||
.api.pty.get({ ptyID: id, location: { directory } })
|
||||
return serverSDK.api.pty
|
||||
.get({ ptyID: id, location: { directory } })
|
||||
.then((result) => result.data.status === "exited")
|
||||
.catch((err) => {
|
||||
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
@@ -84,32 +84,35 @@ function SessionTabEntry(props: {
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId))
|
||||
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
|
||||
const persisted = createMemo(() => tabs.info[props.id])
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
const ctx = props.serverCtx
|
||||
return ctx ? { id: props.tab.sessionId, ctx } : null
|
||||
},
|
||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||
({ id, ctx }) =>
|
||||
ctx.data.session
|
||||
.sync(id)
|
||||
.then(() => ctx.data.session.get(id))
|
||||
.catch(() => undefined),
|
||||
)
|
||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
|
||||
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
|
||||
let prefetched = false
|
||||
|
||||
const rename = async (title: string) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx
|
||||
if (!value || !ctx) return
|
||||
|
||||
ctx.sync.session.remember({ ...value, title })
|
||||
ctx.data.session.remember({ ...value, title })
|
||||
try {
|
||||
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
||||
} catch (err) {
|
||||
const current = session()
|
||||
const currentCtx = props.serverCtx
|
||||
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
|
||||
if (current && currentCtx) currentCtx.data.session.remember({ ...current, title: value.title })
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
@@ -122,19 +125,19 @@ function SessionTabEntry(props: {
|
||||
createEffect(() => {
|
||||
const ctx = props.serverCtx
|
||||
const value = session()
|
||||
if (!ctx || !value || prefetched) return
|
||||
prefetched = true
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
void ctx.sync
|
||||
.ensureDirSyncContext(value.location.directory)
|
||||
.session.sync(value.id)
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
} catch {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
if (!ctx || !value || props.active || ctx.sdk.connection.status() !== "connected") return
|
||||
const timer = window.setTimeout(
|
||||
() =>
|
||||
void Promise.allSettled([
|
||||
ctx.data.session.sync(value.id, { children: true }),
|
||||
ctx.data.session.pending.sync(value.id),
|
||||
ctx.data.session.message.sync(value.id),
|
||||
ctx.data.session.permission.sync(value.id),
|
||||
ctx.data.session.form.sync(value.id),
|
||||
]),
|
||||
300 + props.index * 50,
|
||||
)
|
||||
onCleanup(() => window.clearTimeout(timer))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -254,13 +254,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
}
|
||||
case "home": {
|
||||
const selection = layout.home.selection()
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
|
||||
const project = conn
|
||||
? global
|
||||
.ensureServerCtx(conn)
|
||||
.projects.list()
|
||||
.find((item) => item.worktree === selection.directory)
|
||||
: undefined
|
||||
const conn =
|
||||
global.servers.list().find((item) => ServerConnection.key(item) === selection.server) ??
|
||||
global.servers.list()[0]
|
||||
const projects = conn ? global.ensureServerCtx(conn).projects : undefined
|
||||
const project =
|
||||
projects?.list().find((item) => item.worktree === selection.directory) ??
|
||||
projects?.list().find((item) => item.worktree === projects.last()) ??
|
||||
projects?.list()[0]
|
||||
if (conn && project) {
|
||||
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
|
||||
return
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createScopedCache } from "@/utils/scoped-cache"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useWorkspaceLocation } from "./location"
|
||||
|
||||
export type LineComment = {
|
||||
id: string
|
||||
@@ -212,7 +212,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const cache = createScopedCache(
|
||||
(key) => {
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { SessionInboxInfo, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { createMemo } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import type { createServerSdkContext } from "./server-sdk"
|
||||
import type { createServerSyncContextInner } from "./server-sync"
|
||||
import type { PromptEcho } from "./server-session"
|
||||
import type { State } from "./global-sync/types"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const sessionFields = new Set([
|
||||
"session_status",
|
||||
"session_working",
|
||||
"session_diff",
|
||||
"todo",
|
||||
"permission",
|
||||
"question",
|
||||
"message",
|
||||
"session_message",
|
||||
"part",
|
||||
"part_text_accum_delta",
|
||||
])
|
||||
|
||||
export const createDirSyncContext = (
|
||||
directory: string,
|
||||
serverSync: ReturnType<typeof createServerSyncContextInner>,
|
||||
serverSDK: ReturnType<typeof createServerSdkContext>,
|
||||
) => {
|
||||
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const data = new Proxy({} as State, {
|
||||
get(_, property: keyof State) {
|
||||
if (property === "session_working") return serverSync.session.data.session_working.bind(serverSync.session.data)
|
||||
if (sessionFields.has(property)) return serverSync.session.data[property as keyof typeof serverSync.session.data]
|
||||
return current()[0][property]
|
||||
},
|
||||
})
|
||||
const set = ((...input: unknown[]) => {
|
||||
if (typeof input[0] === "string" && sessionFields.has(input[0])) {
|
||||
return (serverSync.session.set as (...args: unknown[]) => unknown)(...input)
|
||||
}
|
||||
const result = (current()[1] as (...args: unknown[]) => unknown)(...input)
|
||||
if (input[0] === "session") current()[0].session.forEach(serverSync.session.remember)
|
||||
return result
|
||||
}) as SetStoreFunction<State>
|
||||
|
||||
const index = (sessionID: string) => {
|
||||
const session = serverSync.session.get(sessionID)
|
||||
if (!session || session.location.directory !== directory) return
|
||||
const [store, setStore] = current()
|
||||
const result = Binary.search(store.session, session.id, (item) => item.id)
|
||||
if (result.found) {
|
||||
setStore("session", result.index, reconcile(session))
|
||||
return
|
||||
}
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => void draft.splice(result.index, 0, session)),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
set,
|
||||
get status() {
|
||||
return current()[0].status
|
||||
},
|
||||
get ready() {
|
||||
return current()[0].status !== "loading"
|
||||
},
|
||||
get project() {
|
||||
const store = current()[0]
|
||||
const match = Binary.search(serverSync.data.project, store.project, (project) => project.id)
|
||||
if (match.found) return serverSync.data.project[match.index]
|
||||
},
|
||||
session: {
|
||||
remember(session: SessionInfo) {
|
||||
serverSync.session.remember(session)
|
||||
index(session.id)
|
||||
},
|
||||
get(sessionID: string) {
|
||||
const session = serverSync.session.get(sessionID)
|
||||
if (session?.location.directory === directory) return session
|
||||
},
|
||||
inbox: {
|
||||
echo(input: PromptEcho & { directory?: string }) {
|
||||
serverSync.session.inbox.echo(input)
|
||||
},
|
||||
confirm(input: SessionInboxInfo) {
|
||||
return serverSync.session.inbox.confirm(input)
|
||||
},
|
||||
clearEcho(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||
return serverSync.session.inbox.clearEcho(input)
|
||||
},
|
||||
},
|
||||
async sync(sessionID: string, options?: { force?: boolean }) {
|
||||
await serverSync.session.sync(sessionID, options)
|
||||
index(sessionID)
|
||||
},
|
||||
todo: serverSync.session.todo,
|
||||
history: serverSync.session.history,
|
||||
evict(sessionID: string) {
|
||||
serverSync.session.evict(sessionID)
|
||||
},
|
||||
fetch: async (count = 10) => {
|
||||
const [store, setStore] = current()
|
||||
setStore("limit", (value) => value + count)
|
||||
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
|
||||
const sessions = response.data.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),
|
||||
archive: async (sessionID: string) => {
|
||||
// TODO: Restore archiving when the V2 client exposes a session archive API.
|
||||
void sessionID
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
toggle: (name: string) => serverSync.mcp.toggle(directory, name),
|
||||
},
|
||||
absolute,
|
||||
get directory() {
|
||||
return current()[0].path.directory
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
import { useWorkspaceLocation } from "./location"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { createPathHelpers } from "./file/path"
|
||||
@@ -56,8 +55,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
name: "File",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
useSync()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const params = useParams()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
@@ -80,8 +78,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
scope,
|
||||
normalizeDir: path.normalizeDir,
|
||||
list: (dir) =>
|
||||
sdk()
|
||||
.api.file.list({ path: dir, location: { directory: scope() } })
|
||||
serverSDK.api.file
|
||||
.list({ path: dir, location: { directory: scope() } })
|
||||
.then((x) =>
|
||||
x.data.map((entry) => ({
|
||||
...entry,
|
||||
@@ -187,8 +185,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
|
||||
setLoading(file)
|
||||
|
||||
const promise = sdk()
|
||||
.api.file.read({ path: file, location: { directory } })
|
||||
const promise = serverSDK.api.file
|
||||
.read({ path: file, location: { directory } })
|
||||
.then((data) => {
|
||||
if (scope() !== directory) return
|
||||
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import {
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadPathQuery,
|
||||
loadProjectsQuery,
|
||||
loadProvidersQuery,
|
||||
loadReferencesQuery,
|
||||
} from "./bootstrap"
|
||||
import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
@@ -18,44 +10,11 @@ type WorktreeApi = ServerApi["worktree"]
|
||||
|
||||
describe("query keys", () => {
|
||||
test("partitions identical directories by server scope", () => {
|
||||
const api = {} as CatalogApi
|
||||
const location = {} as ServerApi["location"]
|
||||
const remote = "https://debian.example" as typeof ServerScope.local
|
||||
|
||||
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"])
|
||||
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
|
||||
})
|
||||
|
||||
test("loads the current provider and model catalog", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
provider: {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(["provider", input])
|
||||
return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] }
|
||||
},
|
||||
},
|
||||
model: {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(["model", input])
|
||||
return { location: {}, data: [] }
|
||||
},
|
||||
default: async (input: unknown) => {
|
||||
calls.push(["default", input])
|
||||
return { location: {}, data: null }
|
||||
},
|
||||
},
|
||||
} as unknown as CatalogApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([
|
||||
["provider", { location: { directory: "/repo" } }],
|
||||
["model", { location: { directory: "/repo" } }],
|
||||
["default", { location: { directory: "/repo" } }],
|
||||
])
|
||||
expect(result.connected).toEqual(["openai"])
|
||||
})
|
||||
|
||||
test("loads current location metadata", async () => {
|
||||
@@ -73,39 +32,6 @@ describe("query keys", () => {
|
||||
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
||||
})
|
||||
|
||||
test("loads agents from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { location: {}, data: [] }
|
||||
},
|
||||
} as unknown as AgentApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("loads commands from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: {},
|
||||
data: [{ name: "review", template: "Review files" /* source: "command" as const */ }],
|
||||
}
|
||||
},
|
||||
} as unknown as CommandApi
|
||||
|
||||
const result = await loadCommands("/repo", api)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }])
|
||||
})
|
||||
|
||||
test("loads projects from the current endpoint", async () => {
|
||||
const calls: string[] = []
|
||||
const projects = {
|
||||
@@ -161,18 +87,4 @@ describe("query keys", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("loads references from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] }
|
||||
},
|
||||
} as unknown as ReferenceApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,46 +1,26 @@
|
||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||
import type {
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
CatalogApi,
|
||||
CommandInfo,
|
||||
CommandListInput,
|
||||
CommandListOutput,
|
||||
IntegrationListInput,
|
||||
IntegrationListOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
PermissionRequest,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
ProjectListOutput,
|
||||
ReferenceListInput,
|
||||
ReferenceListOutput,
|
||||
ReferenceInfo,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import { batch } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import type { ServerSession } from "../server-session"
|
||||
import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
||||
import { cmp, normalizeProjectInfo } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
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 type { ServerScope } from "@/utils/server-scope"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import { sameDirectory } from "@/utils/workspace"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: NormalizedProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
reload: undefined | "pending" | "complete"
|
||||
@@ -69,12 +49,6 @@ function errors(list: PromiseSettledResult<unknown>[]) {
|
||||
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
||||
}
|
||||
|
||||
const providerRev = new Map<string, number>()
|
||||
|
||||
export function clearProviderRev(scope: ServerScope, directory: string) {
|
||||
providerRev.delete(ScopedKey.from(scope, directory))
|
||||
}
|
||||
|
||||
function runAll(list: Array<() => Promise<unknown>>) {
|
||||
return Promise.allSettled(list.map((item) => item()))
|
||||
}
|
||||
@@ -109,10 +83,6 @@ type ProjectApi = {
|
||||
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||
|
||||
type McpApi = ServerApi["mcp"]
|
||||
type PermissionApi = ServerApi["permission"]
|
||||
type VcsApi = ServerApi["vcs"]
|
||||
|
||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "project"],
|
||||
@@ -150,7 +120,7 @@ export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, work
|
||||
})
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
serverAPI: CatalogApi & {
|
||||
serverAPI: {
|
||||
readonly location: LocationApi
|
||||
readonly project: ProjectApi
|
||||
readonly worktree: WorktreeApi
|
||||
@@ -164,7 +134,6 @@ export async function bootstrapGlobal(input: {
|
||||
}) {
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
||||
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverAPI)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||
() =>
|
||||
input.queryClient
|
||||
@@ -180,97 +149,10 @@ export async function bootstrapGlobal(input: {
|
||||
// })
|
||||
}
|
||||
|
||||
function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
|
||||
return input.reduce<Record<string, T[]>>((acc, item) => {
|
||||
if (!item?.id || !item.sessionID) return acc
|
||||
const list = acc[item.sessionID]
|
||||
if (list) list.push(item)
|
||||
if (!list) acc[item.sessionID] = [item]
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function projectID(directory: string, projects: Project[]) {
|
||||
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||
}
|
||||
|
||||
function mergeSession(setStore: SetStoreFunction<State>, session: SessionInfo) {
|
||||
setStore("session", (list) => {
|
||||
const next = list.slice()
|
||||
const idx = next.findIndex((item) => item.id >= session.id)
|
||||
if (idx === -1) return [...next, session]
|
||||
if (next[idx]?.id === session.id) {
|
||||
next[idx] = session
|
||||
return next
|
||||
}
|
||||
next.splice(idx, 0, session)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function warmSessions(input: {
|
||||
ids: string[]
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
api: SessionApi
|
||||
}) {
|
||||
const known = new Set(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(
|
||||
ids.map((sessionID) =>
|
||||
retry(() => input.api.get({ sessionID })).then((session) => mergeSession(input.setStore, session)),
|
||||
),
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: CatalogApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "providers"],
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
const location = directory ? { location: { directory } } : undefined
|
||||
const [providers, models, defaultModel] = await Promise.all([
|
||||
sdk.provider.list(location),
|
||||
sdk.model.list(location),
|
||||
sdk.model.default(location),
|
||||
])
|
||||
return normalizeProviderList(providers.data, models.data, defaultModel.data)
|
||||
}),
|
||||
})
|
||||
|
||||
type AgentListApi = {
|
||||
readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
|
||||
}
|
||||
|
||||
type CommandListApi = {
|
||||
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
||||
}
|
||||
|
||||
type IntegrationListApi = {
|
||||
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
|
||||
}
|
||||
|
||||
type ReferenceListApi = {
|
||||
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
||||
}
|
||||
|
||||
export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: AgentListApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "agents"],
|
||||
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
|
||||
})
|
||||
|
||||
export const loadIntegrationsQuery = (scope: ServerScope, directory: string | null, sdk: IntegrationListApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "integrations"] as const,
|
||||
queryFn: () =>
|
||||
retry(() => sdk.list(directory ? { location: { directory } } : undefined).then((result) => result.data)),
|
||||
})
|
||||
|
||||
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
|
||||
retry(() => api.list({ location: { directory } }).then((result) => result.data))
|
||||
|
||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: LocationApi) =>
|
||||
queryOptions<Path>({
|
||||
queryKey: [scope, directory, "path"],
|
||||
@@ -284,130 +166,37 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, api:
|
||||
})),
|
||||
})
|
||||
|
||||
export const loadReferencesQuery = (scope: ServerScope, directory: string, api: ReferenceListApi) =>
|
||||
queryOptions<ReferenceInfo[]>({
|
||||
queryKey: [scope, directory, "references"] as const,
|
||||
queryFn: () => retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
scope: ServerScope
|
||||
mcp: boolean
|
||||
api: CatalogApi & {
|
||||
readonly agent: AgentListApi
|
||||
readonly command: CommandListApi
|
||||
readonly mcp: McpApi
|
||||
readonly permission: PermissionApi
|
||||
api: {
|
||||
readonly project: ProjectApi
|
||||
readonly reference: ReferenceListApi
|
||||
readonly session: SessionApi
|
||||
readonly vcs: VcsApi
|
||||
readonly location: LocationApi
|
||||
}
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
loadSessions: (directory: string) => Promise<void> | void
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
global: {
|
||||
config: Config
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: NormalizedProviderListResponse
|
||||
}
|
||||
queryClient: QueryClient
|
||||
session?: ServerSession
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
|
||||
if (seededProject) input.setStore("project", seededProject)
|
||||
if (seededPath) input.setStore("path", seededPath)
|
||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||
input.setStore("config", reconcile(input.global.config, { merge: false }))
|
||||
}
|
||||
if (loading) input.setStore("status", "partial")
|
||||
|
||||
const revKey = ScopedKey.from(input.scope, input.directory)
|
||||
const rev = (providerRev.get(revKey) ?? 0) + 1
|
||||
providerRev.set(revKey, rev)
|
||||
const slow = [
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent))
|
||||
.then((data) => input.setStore("agent", data)),
|
||||
!seededProject &&
|
||||
(() =>
|
||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||
input.setStore("project", project.id),
|
||||
)),
|
||||
!seededPath &&
|
||||
(() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location))
|
||||
.then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
input.mcp &&
|
||||
(() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.api.permission.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
.then((permissions) => {
|
||||
const ids = permissions.map((permission) => permission.sessionID)
|
||||
const grouped = groupBySession(
|
||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.permission ?? input.store.permission
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("permission", sessionID, [])
|
||||
if (!input.session) input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
const value = reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
)
|
||||
if (input.session) input.session.set("permission", sessionID, value)
|
||||
if (!input.session) input.setStore("permission", sessionID, value)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
input.mcp &&
|
||||
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
|
||||
.catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
].filter(Boolean) as (() => Promise<any>)[]
|
||||
|
||||
await waitForPaint()
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { createRoot, getOwner, type Owner } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { State } from "./types"
|
||||
import type { QueryOptionsApi } from "../server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
|
||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
||||
let providerQuerySuccess = true
|
||||
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
|
||||
store[0],
|
||||
store[1],
|
||||
@@ -17,12 +16,26 @@ const persist: typeof import("@/utils/persist").persisted = (_target, store) =>
|
||||
]
|
||||
|
||||
const child = () => createStore({} as State)
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
const path = { state: "", config: "", worktree: "", directory: "", home: "" }
|
||||
const data = {
|
||||
location: {
|
||||
info: () => undefined,
|
||||
agent: { list: () => undefined },
|
||||
command: { list: () => undefined },
|
||||
reference: { list: () => undefined },
|
||||
provider: { list: () => undefined },
|
||||
model: { list: () => undefined, default: () => undefined },
|
||||
mcp: {
|
||||
server: { list: () => undefined },
|
||||
resource: { list: () => undefined },
|
||||
},
|
||||
vcs: { info: () => undefined },
|
||||
},
|
||||
} as unknown as Data
|
||||
|
||||
const queryOptionsApi = {
|
||||
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
|
||||
projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
|
||||
providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }),
|
||||
path: (directory: string | null) => ({
|
||||
queryKey: [directory, "path"],
|
||||
queryFn: async () => ({
|
||||
@@ -60,7 +73,7 @@ beforeAll(async () => {
|
||||
return options().queryKey?.[1] === "path"
|
||||
},
|
||||
get isSuccess() {
|
||||
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess
|
||||
return false
|
||||
},
|
||||
get isRefetchError() {
|
||||
return false
|
||||
@@ -69,7 +82,6 @@ beforeAll(async () => {
|
||||
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
|
||||
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
|
||||
if (options().queryKey?.[1] === "lsp") return []
|
||||
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
@@ -100,7 +112,8 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
|
||||
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
||||
@@ -134,7 +147,8 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -144,7 +158,6 @@ describe("createChildStoreManager", () => {
|
||||
const [store] = manager.child("/project")
|
||||
|
||||
expect(store.status).toBe("loading")
|
||||
expect(store.limit).toBe(5)
|
||||
expect(bootstraps).toEqual(["/project"])
|
||||
} finally {
|
||||
dispose()
|
||||
@@ -167,7 +180,8 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -198,7 +212,8 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -214,7 +229,7 @@ describe("createChildStoreManager", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("enables MCP only when requested for the directory", () => {
|
||||
test("syncs MCP only when requested for the directory", () => {
|
||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||
const offset = querySingles.length
|
||||
const mcpLoads: string[] = []
|
||||
@@ -234,30 +249,21 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
if (!manager) throw new Error("manager required")
|
||||
const [store, setStore] = manager.child("/project", { bootstrap: false })
|
||||
expect(querySingles.length - offset).toBe(6)
|
||||
const query = querySingles[offset + 1]
|
||||
const resourceQuery = querySingles[offset + 2]
|
||||
if (!query) throw new Error("query required")
|
||||
if (!resourceQuery) throw new Error("resource query required")
|
||||
expect(query().enabled).toBe(false)
|
||||
expect(resourceQuery().enabled).toBe(false)
|
||||
const [, setStore] = manager.child("/project", { bootstrap: false })
|
||||
expect(querySingles.length - offset).toBe(1)
|
||||
|
||||
setStore("status", "complete")
|
||||
manager.child("/project", { bootstrap: false, mcp: true })
|
||||
expect(query().enabled).toBe(true)
|
||||
expect(resourceQuery().enabled).toBe(true)
|
||||
expect(store.mcp).toEqual({ demo: { status: "disabled" } })
|
||||
expect(mcpLoads).toEqual(["/project"])
|
||||
|
||||
manager.disableMcp("/project")
|
||||
expect(query().enabled).toBe(false)
|
||||
expect(manager.mcp("/project")).toBe(false)
|
||||
} finally {
|
||||
dispose()
|
||||
@@ -284,7 +290,8 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -293,11 +300,8 @@ describe("createChildStoreManager", () => {
|
||||
const [store] = manager.child("/project", { bootstrap: false })
|
||||
const queries = querySingles.slice(offset)
|
||||
|
||||
expect(queries).toHaveLength(6)
|
||||
expect(queries).toHaveLength(1)
|
||||
expect(queries[0]?.().enabled).toBe(false)
|
||||
expect(queries[3]?.().enabled).toBe(false)
|
||||
expect(queries[4]?.().enabled).toBe(false)
|
||||
expect(queries[5]?.().enabled).toBe(false)
|
||||
expect(store.path.directory).toBe("/project")
|
||||
expect(store.provider_ready).toBe(false)
|
||||
expect(store.lsp_ready).toBe(false)
|
||||
@@ -305,9 +309,6 @@ describe("createChildStoreManager", () => {
|
||||
|
||||
manager.child("/project")
|
||||
expect(queries[0]?.().enabled).toBe(true)
|
||||
expect(queries[3]?.().enabled).toBe(true)
|
||||
expect(queries[4]?.().enabled).toBe(true)
|
||||
expect(queries[5]?.().enabled).toBe(true)
|
||||
expect(bootstraps).toEqual(["/project"])
|
||||
|
||||
manager.child("/project", { bootstrap: false })
|
||||
@@ -317,9 +318,8 @@ describe("createChildStoreManager", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("does not mark a cancelled provider query as ready", () => {
|
||||
test("does not mark unsynced provider data as ready", () => {
|
||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||
providerQuerySuccess = false
|
||||
|
||||
const dispose = createOwner((owner) => {
|
||||
manager = createChildStoreManager({
|
||||
@@ -334,7 +334,8 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -343,7 +344,6 @@ describe("createChildStoreManager", () => {
|
||||
const [store] = manager.child("/cancelled")
|
||||
expect(store.provider_ready).toBe(false)
|
||||
} finally {
|
||||
providerQuerySuccess = true
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
@@ -365,7 +365,8 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -374,11 +375,9 @@ describe("createChildStoreManager", () => {
|
||||
manager.child("/handshake")
|
||||
const queries = querySingles.slice(offset)
|
||||
expect(queries[0]?.().enabled).toBe(false)
|
||||
expect(queries[4]?.().enabled).toBe(false)
|
||||
|
||||
connected = true
|
||||
expect(queries[0]?.().enabled).toBe(true)
|
||||
expect(queries[4]?.().enabled).toBe(true)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { VcsInfo } from "@/types"
|
||||
import type { Path, VcsInfo } from "@/types"
|
||||
import {
|
||||
DIR_IDLE_TTL_MS,
|
||||
MAX_DIR_STORES,
|
||||
@@ -17,8 +17,9 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { QueryOptionsApi } from "../server-sync"
|
||||
import { directoryKey, type DirectoryKey } from "./utils"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
|
||||
export function createChildStoreManager(input: {
|
||||
owner: Owner
|
||||
@@ -32,8 +33,9 @@ export function createChildStoreManager(input: {
|
||||
onDispose: (directory: string) => void
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
queryOptions: QueryOptionsApi
|
||||
data: Data
|
||||
global: {
|
||||
provider: NormalizedProviderListResponse
|
||||
path: Path
|
||||
}
|
||||
}) {
|
||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||
@@ -45,7 +47,6 @@ export function createChildStoreManager(input: {
|
||||
const ownerPins = new WeakMap<object, Set<string>>()
|
||||
const disposers = new Map<string, () => void>()
|
||||
const mcpDirectories = new Set<string>()
|
||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
||||
const activeDirectories = new Set<string>()
|
||||
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
||||
|
||||
@@ -120,7 +121,6 @@ export function createChildStoreManager(input: {
|
||||
iconCache.delete(key)
|
||||
lifecycle.delete(key)
|
||||
mcpDirectories.delete(key)
|
||||
mcpToggles.delete(key)
|
||||
activeDirectories.delete(key)
|
||||
activationToggles.delete(key)
|
||||
const dispose = disposers.get(key)
|
||||
@@ -186,78 +186,63 @@ export function createChildStoreManager(input: {
|
||||
createRoot((dispose) => {
|
||||
const initialMeta = meta[0].value
|
||||
const initialIcon = icon[0].value
|
||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||
|
||||
const pathQuery = useQuery(() => ({
|
||||
...input.queryOptions.path(key),
|
||||
enabled: input.connected() && instanceQueriesEnabled(),
|
||||
}))
|
||||
const mcpQuery = useQuery(() => ({
|
||||
...input.queryOptions.mcp(key),
|
||||
enabled: input.connected() && mcpEnabled(),
|
||||
}))
|
||||
const mcpResourceQuery = useQuery(() => ({
|
||||
...input.queryOptions.mcpResources(key),
|
||||
enabled: input.connected() && mcpEnabled(),
|
||||
}))
|
||||
const lspQuery = useQuery(() => ({
|
||||
...input.queryOptions.lsp(key),
|
||||
enabled: input.connected() && instanceQueriesEnabled(),
|
||||
}))
|
||||
const providerQuery = useQuery(() => ({
|
||||
...input.queryOptions.providers(key),
|
||||
enabled: input.connected() && instanceQueriesEnabled(),
|
||||
}))
|
||||
const referenceQuery = useQuery(() => ({
|
||||
...input.queryOptions.references(key),
|
||||
enabled: input.connected() && instanceQueriesEnabled(),
|
||||
}))
|
||||
|
||||
const child = createStore<State>({
|
||||
project: "",
|
||||
projectMeta: initialMeta,
|
||||
icon: initialIcon,
|
||||
get provider_ready() {
|
||||
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError)
|
||||
return (
|
||||
input.data.location.provider.list({ directory }) !== undefined &&
|
||||
input.data.location.model.list({ directory }) !== undefined
|
||||
)
|
||||
},
|
||||
get provider() {
|
||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY
|
||||
const provider = providerQuery.data
|
||||
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
|
||||
return provider
|
||||
const provider = input.data.location.provider.list({ directory })
|
||||
const model = input.data.location.model.list({ directory })
|
||||
if (!provider || !model) return { all: new Map(), connected: [], default: {} }
|
||||
return normalizeProviderList(provider, model)
|
||||
},
|
||||
config: {},
|
||||
get path() {
|
||||
const EMPTY = { state: "", config: "", worktree: "", directory, home: "" }
|
||||
if (pathQuery.isLoading) return EMPTY
|
||||
return pathQuery.data ?? EMPTY
|
||||
const location = input.data.location.info({ directory })
|
||||
return {
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location?.project.directory ?? "",
|
||||
directory: location?.directory ?? directory,
|
||||
home: input.global.path.home,
|
||||
}
|
||||
},
|
||||
status: "loading" as const,
|
||||
agent: [],
|
||||
command: [],
|
||||
get agent() {
|
||||
return normalizeAgentList(input.data.location.agent.list({ directory }) ?? [])
|
||||
},
|
||||
get command() {
|
||||
return input.data.location.command.list({ directory }) ?? []
|
||||
},
|
||||
get reference() {
|
||||
return referenceQuery.isLoading ? [] : (referenceQuery.data ?? [])
|
||||
return input.data.location.reference.list({ directory }) ?? []
|
||||
},
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_working(id: string) {
|
||||
const type = this.session_status[id]?.type
|
||||
return (type ?? "idle") !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
get mcp_ready() {
|
||||
return !mcpQuery.isLoading
|
||||
return input.data.location.mcp.server.list({ directory }) !== undefined
|
||||
},
|
||||
get mcp() {
|
||||
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
|
||||
return Object.fromEntries(
|
||||
(input.data.location.mcp.server.list({ directory }) ?? []).map((server) => [server.name, server.status]),
|
||||
)
|
||||
},
|
||||
get mcp_resource() {
|
||||
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
||||
return Object.fromEntries(
|
||||
(input.data.location.mcp.resource.list({ directory }) ?? []).map((resource) => [
|
||||
`${resource.server}:${resource.uri}`,
|
||||
resource,
|
||||
]),
|
||||
)
|
||||
},
|
||||
get lsp_ready() {
|
||||
return instanceQueriesEnabled() && !lspQuery.isLoading
|
||||
@@ -265,16 +250,14 @@ export function createChildStoreManager(input: {
|
||||
get lsp() {
|
||||
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
||||
},
|
||||
vcs: vcsStore.value,
|
||||
limit: 5,
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
get vcs() {
|
||||
const vcs = input.data.location.vcs.info({ directory })
|
||||
if (!vcs) return vcsStore.value
|
||||
return { branch: vcs.branch.current, default_branch: vcs.branch.default }
|
||||
},
|
||||
})
|
||||
children[key] = child
|
||||
disposers.set(key, dispose)
|
||||
mcpToggles.set(key, setMcpEnabled)
|
||||
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||
|
||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||
@@ -338,14 +321,11 @@ export function createChildStoreManager(input: {
|
||||
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
|
||||
if (mcpDirectories.has(key)) return
|
||||
mcpDirectories.add(key)
|
||||
mcpToggles.get(key)?.(true)
|
||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||
}
|
||||
|
||||
// Passive Home/project metadata reads must not initialize the directory.
|
||||
// Passive project metadata reads must not initialize the directory.
|
||||
// A real directory access enables these queries once for the store lifetime.
|
||||
// TODO(v2): After Home switches to v2.project.list and root-filtered,
|
||||
// updated-time v2.session.list, remove any Home-only passive child creation.
|
||||
function activate(key: DirectoryKey) {
|
||||
if (activeDirectories.has(key)) return
|
||||
activeDirectories.add(key)
|
||||
@@ -355,7 +335,6 @@ export function createChildStoreManager(input: {
|
||||
function disableMcp(directory: string) {
|
||||
const key = directoryKey(directory)
|
||||
if (!mcpDirectories.delete(key)) return
|
||||
mcpToggles.get(key)?.(false)
|
||||
}
|
||||
|
||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||
|
||||
@@ -1,566 +1,61 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, Project } from "@/types"
|
||||
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Project } from "@/types"
|
||||
import type { State } from "./types"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
||||
|
||||
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
|
||||
({
|
||||
id: input.id,
|
||||
parentID: input.parentID,
|
||||
time: {
|
||||
created: 1,
|
||||
updated: 1,
|
||||
archived: input.archived,
|
||||
},
|
||||
}) as SessionInfo
|
||||
|
||||
const userMessage = (id: string, sessionID: string, created = 1) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created },
|
||||
agent: "assistant",
|
||||
model: { providerID: "openai", modelID: "gpt" },
|
||||
}) as Message
|
||||
|
||||
const textPart = (id: string, sessionID: string, messageID: string) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: id,
|
||||
}) as Part
|
||||
|
||||
const permissionRequest = (id: string, sessionID: string, title = id) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
action: title,
|
||||
resources: ["*"],
|
||||
metadata: {},
|
||||
save: [],
|
||||
}) as PermissionRequest
|
||||
|
||||
const baseState = (input: Partial<State> = {}) =>
|
||||
({
|
||||
status: "complete",
|
||||
agent: [],
|
||||
command: [],
|
||||
project: "",
|
||||
projectMeta: undefined,
|
||||
icon: undefined,
|
||||
provider: {} as State["provider"],
|
||||
config: {} as State["config"],
|
||||
path: { directory: "/tmp" } as State["path"],
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
mcp: {},
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
limit: 10,
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
...input,
|
||||
}) as State
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
|
||||
|
||||
describe("applyGlobalEvent", () => {
|
||||
test("upserts project.updated in sorted position", () => {
|
||||
const project = [{ id: "a" }, { id: "c" }] as Project[]
|
||||
let refreshCount = 0
|
||||
const projects = [{ id: "b", worktree: "/b" }] as Project[]
|
||||
let next = projects
|
||||
applyGlobalEvent({
|
||||
event: { type: "project.updated", properties: { id: "b" } },
|
||||
project,
|
||||
refresh: () => {
|
||||
refreshCount += 1
|
||||
},
|
||||
setGlobalProject(next) {
|
||||
if (typeof next === "function") next(project)
|
||||
event: { type: "project.updated", properties: { id: "a", worktree: "/a" } },
|
||||
project: projects,
|
||||
setGlobalProject: (value) => {
|
||||
next = typeof value === "function" ? value(next) : value
|
||||
},
|
||||
refresh() {},
|
||||
})
|
||||
|
||||
expect(project.map((x) => x.id)).toEqual(["a", "b", "c"])
|
||||
expect(refreshCount).toBe(0)
|
||||
expect(next.map((project) => project.id)).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("handles global.disposed by triggering refresh", () => {
|
||||
let refreshCount = 0
|
||||
test("refreshes on global disposal", () => {
|
||||
let refreshed = false
|
||||
applyGlobalEvent({
|
||||
event: { type: "global.disposed" },
|
||||
project: [],
|
||||
refresh: () => {
|
||||
refreshCount += 1
|
||||
},
|
||||
setGlobalProject() {},
|
||||
refresh: () => (refreshed = true),
|
||||
})
|
||||
|
||||
expect(refreshCount).toBe(1)
|
||||
})
|
||||
|
||||
test("leaves server.connected refresh to the connection sync", () => {
|
||||
let refreshCount = 0
|
||||
applyGlobalEvent({
|
||||
event: { type: "server.connected" },
|
||||
project: [],
|
||||
refresh: () => {
|
||||
refreshCount += 1
|
||||
},
|
||||
setGlobalProject() {},
|
||||
})
|
||||
|
||||
expect(refreshCount).toBe(0)
|
||||
expect(refreshed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyDirectoryEvent", () => {
|
||||
test("initializes text delta accumulation from the current part text", () => {
|
||||
const part = { ...textPart("part", "session", "message"), text: "existing" }
|
||||
const [store, setStore] = createStore(baseState({ part: { message: [part] } }))
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: {
|
||||
type: "message.part.delta",
|
||||
properties: { messageID: "message", partID: "part", field: "text", delta: " appended" },
|
||||
},
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.part_text_accum_delta.part).toBe("existing appended")
|
||||
expect((store.part.message?.[0] as { text: string }).text).toBe("existing appended")
|
||||
})
|
||||
|
||||
test("preserves a Home-specific retained session limit", () => {
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
limit: 1,
|
||||
session: [rootSession({ id: "a" }), rootSession({ id: "b" }), rootSession({ id: "c" })],
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.created", properties: { info: rootSession({ id: "d" }) } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
retainedLimit: 3,
|
||||
})
|
||||
|
||||
expect(store.session).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("inserts root sessions in sorted order and updates sessionTotal", () => {
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
session: [rootSession({ id: "b" })],
|
||||
sessionTotal: 1,
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.session.map((x) => x.id)).toEqual(["a", "b"])
|
||||
expect(store.sessionTotal).toBe(2)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.sessionTotal).toBe(2)
|
||||
})
|
||||
|
||||
test("cleans session caches when archived", () => {
|
||||
const message = userMessage("msg_1", "ses_1")
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })],
|
||||
sessionTotal: 2,
|
||||
message: { ses_1: [message] },
|
||||
part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
|
||||
session_diff: { ses_1: [] },
|
||||
todo: { ses_1: [] },
|
||||
permission: { ses_1: [] },
|
||||
session_status: { ses_1: { type: "busy" } },
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.session.map((x) => x.id)).toEqual(["ses_2"])
|
||||
expect(store.sessionTotal).toBe(1)
|
||||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part[message.id]).toBeUndefined()
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.todo.ses_1).toBeUndefined()
|
||||
expect(store.permission.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
})
|
||||
|
||||
test("ignores an archived session absent from a passive directory store", () => {
|
||||
const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 }))
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.session).toEqual([])
|
||||
expect(store.sessionTotal).toBe(0)
|
||||
})
|
||||
|
||||
test("cleans session caches when deleted and decrements only root totals", () => {
|
||||
const cases = [
|
||||
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false },
|
||||
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true },
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
const message = userMessage("msg_1", item.info.id)
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
session: [
|
||||
rootSession({ id: "ses_1" }),
|
||||
rootSession({ id: "ses_2", parentID: "ses_1" }),
|
||||
rootSession({ id: "ses_3" }),
|
||||
],
|
||||
sessionTotal: 2,
|
||||
message: { [item.info.id]: [message] },
|
||||
part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] },
|
||||
session_diff: { [item.info.id]: [] },
|
||||
todo: { [item.info.id]: [] },
|
||||
permission: { [item.info.id]: [] },
|
||||
session_status: { [item.info.id]: { type: "busy" } },
|
||||
}),
|
||||
)
|
||||
|
||||
test("updates vcs and routes refresh events", () => {
|
||||
const [store, setStore] = createStore({ vcs: { branch: "old" } } as State)
|
||||
const pushed: string[] = []
|
||||
let lsp = 0
|
||||
let references = 0
|
||||
const apply = (type: string, properties?: unknown) =>
|
||||
applyDirectoryEvent({
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
properties: item.current ? { sessionID: item.info.id } : { info: item.info },
|
||||
},
|
||||
event: { type, properties },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
directory: "/repo",
|
||||
push: (directory) => pushed.push(directory),
|
||||
loadLsp: () => lsp++,
|
||||
loadReferences: () => references++,
|
||||
})
|
||||
|
||||
expect(store.session.find((x) => x.id === item.info.id)).toBeUndefined()
|
||||
expect(store.sessionTotal).toBe(item.expectedTotal)
|
||||
expect(store.message[item.info.id]).toBeUndefined()
|
||||
expect(store.part[message.id]).toBeUndefined()
|
||||
expect(store.session_diff[item.info.id]).toBeUndefined()
|
||||
expect(store.todo[item.info.id]).toBeUndefined()
|
||||
expect(store.permission[item.info.id]).toBeUndefined()
|
||||
expect(store.session_status[item.info.id]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
apply("vcs.branch.updated", { branch: "main" })
|
||||
apply("server.instance.disposed")
|
||||
apply("lsp.updated")
|
||||
apply("reference.updated")
|
||||
|
||||
test("cleans caches for trimmed sessions on session.created", () => {
|
||||
const dropped = rootSession({ id: "ses_b" })
|
||||
const kept = rootSession({ id: "ses_a" })
|
||||
const message = userMessage("msg_1", dropped.id)
|
||||
const todos: string[] = []
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
limit: 1,
|
||||
session: [dropped],
|
||||
message: { [dropped.id]: [message] },
|
||||
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
|
||||
session_diff: { [dropped.id]: [] },
|
||||
todo: { [dropped.id]: [] },
|
||||
permission: { [dropped.id]: [] },
|
||||
session_status: { [dropped.id]: { type: "busy" } },
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.created", properties: { info: kept } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
setSessionTodo(sessionID, value) {
|
||||
if (value !== undefined) return
|
||||
todos.push(sessionID)
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.session.map((x) => x.id)).toEqual([kept.id])
|
||||
expect(store.message[dropped.id]).toBeUndefined()
|
||||
expect(store.part[message.id]).toBeUndefined()
|
||||
expect(store.session_diff[dropped.id]).toBeUndefined()
|
||||
expect(store.todo[dropped.id]).toBeUndefined()
|
||||
expect(store.permission[dropped.id]).toBeUndefined()
|
||||
expect(store.session_status[dropped.id]).toBeUndefined()
|
||||
expect(todos).toEqual([dropped.id])
|
||||
})
|
||||
|
||||
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
session: [rootSession({ id: "ses_keep" })],
|
||||
part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] },
|
||||
}),
|
||||
)
|
||||
|
||||
cleanupDroppedSessionCaches(store, setStore, store.session)
|
||||
|
||||
expect(store.part.msg_1).toBeUndefined()
|
||||
})
|
||||
|
||||
test("upserts and removes messages while clearing orphaned parts", () => {
|
||||
const sessionID = "ses_1"
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
message: { [sessionID]: [userMessage("msg_z", sessionID, 1), userMessage("msg_b", sessionID, 3)] },
|
||||
part: { msg_a: [textPart("prt_1", sessionID, "msg_a")] },
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "message.updated", properties: { info: userMessage("msg_a", sessionID, 2) } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a", "msg_b"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
...userMessage("msg_a", sessionID, 2),
|
||||
role: "assistant",
|
||||
} as Message,
|
||||
},
|
||||
},
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.message[sessionID]?.find((x) => x.id === "msg_a")?.role).toBe("assistant")
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "message.removed", properties: { sessionID, messageID: "msg_a" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_b"])
|
||||
expect(store.part.msg_a).toBeUndefined()
|
||||
})
|
||||
|
||||
test("upserts and prunes message parts", () => {
|
||||
const sessionID = "ses_1"
|
||||
const messageID = "msg_1"
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
part: { [messageID]: [textPart("prt_1", sessionID, messageID), textPart("prt_3", sessionID, messageID)] },
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "message.part.updated", properties: { part: textPart("prt_2", sessionID, messageID) } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.part[messageID]?.map((x) => x.id)).toEqual(["prt_1", "prt_2", "prt_3"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
...textPart("prt_2", sessionID, messageID),
|
||||
text: "changed",
|
||||
} as Part,
|
||||
},
|
||||
},
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
const updated = store.part[messageID]?.find((x) => x.id === "prt_2")
|
||||
expect(updated?.type).toBe("text")
|
||||
if (updated?.type === "text") expect(updated.text).toBe("changed")
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "message.part.removed", properties: { messageID, partID: "prt_1" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
applyDirectoryEvent({
|
||||
event: { type: "message.part.removed", properties: { messageID, partID: "prt_2" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
applyDirectoryEvent({
|
||||
event: { type: "message.part.removed", properties: { messageID, partID: "prt_3" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(store.part[messageID]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("tracks permission request lifecycles", () => {
|
||||
const sessionID = "ses_1"
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] },
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID) },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_2", "perm_3"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID, "updated") },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.action).toBe("updated")
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"])
|
||||
})
|
||||
|
||||
test("updates vcs branch in store and cache", () => {
|
||||
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
||||
const [cacheStore, setCacheStore] = createStore({
|
||||
value: { branch: "main", default_branch: "main" } as State["vcs"],
|
||||
})
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
vcsCache: {
|
||||
store: cacheStore,
|
||||
setStore: setCacheStore,
|
||||
ready: () => true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||
})
|
||||
|
||||
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||
const [store, setStore] = createStore(baseState())
|
||||
const pushes: string[] = []
|
||||
let lspLoads = 0
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "server.instance.disposed" },
|
||||
store,
|
||||
setStore,
|
||||
push(directory) {
|
||||
pushes.push(directory)
|
||||
},
|
||||
directory: "/tmp",
|
||||
loadLsp() {
|
||||
lspLoads += 1
|
||||
},
|
||||
})
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "lsp.updated" },
|
||||
store,
|
||||
setStore,
|
||||
push(directory) {
|
||||
pushes.push(directory)
|
||||
},
|
||||
directory: "/tmp",
|
||||
loadLsp() {
|
||||
lspLoads += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(pushes).toEqual(["/tmp"])
|
||||
expect(lspLoads).toBe(1)
|
||||
expect(store.vcs?.branch).toBe("main")
|
||||
expect(pushed).toEqual(["/repo"])
|
||||
expect(lsp).toBe(1)
|
||||
expect(references).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,27 +1,7 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Message, Part, Project, Todo } from "@/types"
|
||||
import type { FileDiffInfo, PermissionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import { produce, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Project } from "@/types"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||
import { messageKey } from "@/utils/session-message"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const SESSION_CONTENT_EVENTS = new Set([
|
||||
"session.diff",
|
||||
"todo.updated",
|
||||
"session.status",
|
||||
"message.updated",
|
||||
"message.removed",
|
||||
"message.part.updated",
|
||||
"message.part.removed",
|
||||
"message.part.delta",
|
||||
"permission.asked",
|
||||
"permission.replied",
|
||||
])
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
@@ -35,7 +15,7 @@ export function applyGlobalEvent(input: {
|
||||
}
|
||||
if (input.event.type !== "project.updated") return
|
||||
const properties = input.event.properties as Project
|
||||
const result = Binary.search(input.project, properties.id, (s) => s.id)
|
||||
const result = Binary.search(input.project, properties.id, (project) => project.id)
|
||||
if (result.found) {
|
||||
input.setGlobalProject(
|
||||
produce((draft) => {
|
||||
@@ -51,48 +31,6 @@ export function applyGlobalEvent(input: {
|
||||
)
|
||||
}
|
||||
|
||||
function cleanupSessionCaches(
|
||||
setStore: SetStoreFunction<State>,
|
||||
sessionID: string,
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||
) {
|
||||
if (!sessionID) return
|
||||
setSessionTodo?.(sessionID, undefined)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, [sessionID])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function cleanupDroppedSessionCaches(
|
||||
store: Store<State>,
|
||||
setStore: SetStoreFunction<State>,
|
||||
next: SessionInfo[],
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||
) {
|
||||
const keep = new Set(next.map((item) => item.id))
|
||||
const stale = [
|
||||
...Object.keys(store.message),
|
||||
...Object.keys(store.session_diff),
|
||||
...Object.keys(store.todo),
|
||||
...Object.keys(store.permission),
|
||||
...Object.keys(store.session_status),
|
||||
...Object.values(store.part)
|
||||
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
|
||||
.filter((sessionID): sessionID is string => !!sessionID),
|
||||
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
|
||||
if (stale.length === 0) return
|
||||
for (const sessionID of stale) {
|
||||
setSessionTodo?.(sessionID, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, stale)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function applyDirectoryEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
store: Store<State>
|
||||
@@ -102,339 +40,24 @@ export function applyDirectoryEvent(input: {
|
||||
loadLsp: () => void
|
||||
loadReferences?: () => void
|
||||
vcsCache?: VcsCache
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
|
||||
retainedLimit?: number
|
||||
sessionContent?: boolean
|
||||
permission?: State["permission"]
|
||||
}) {
|
||||
const event = input.event
|
||||
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return
|
||||
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed": {
|
||||
switch (input.event.type) {
|
||||
case "server.instance.disposed":
|
||||
input.push(input.directory)
|
||||
return
|
||||
}
|
||||
case "session.created": {
|
||||
const info = (event.properties as { info: SessionInfo }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
input.setStore("session", result.index, reconcile(info))
|
||||
break
|
||||
}
|
||||
const next = input.store.session.slice()
|
||||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
||||
break
|
||||
}
|
||||
case "session.updated": {
|
||||
const info = (event.properties as { info: SessionInfo }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
if (info.time.archived) {
|
||||
if (!result.found) break
|
||||
if (input.store.session[result.index]!.time.archived === info.time.archived) break
|
||||
input.setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
if (result.found) {
|
||||
input.setStore("session", result.index, reconcile(info))
|
||||
break
|
||||
}
|
||||
const next = input.store.session.slice()
|
||||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||
break
|
||||
}
|
||||
case "session.deleted": {
|
||||
const properties = event.properties as { sessionID?: string; info?: SessionInfo }
|
||||
const sessionID = properties.info?.id ?? properties.sessionID
|
||||
if (!sessionID) break
|
||||
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
|
||||
const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined)
|
||||
if (result.found) {
|
||||
input.setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
|
||||
if (info?.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "worktree.resolved": {
|
||||
const properties = event.properties as { projectID: string; directory: string; previous: string }
|
||||
input.store.session.forEach((session, index) => {
|
||||
const adopted = Worktree.adopt(
|
||||
{ projectID: session.projectID, directory: session.location.directory },
|
||||
properties,
|
||||
)
|
||||
if (!adopted) return
|
||||
input.setStore("session", index, (current) => ({ ...current, ...adopted }))
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.renamed": {
|
||||
const properties = event.properties as { sessionID: string; title: string }
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
input.setStore("session", result.index, (session) => ({
|
||||
...session,
|
||||
title: properties.title,
|
||||
time: { ...session.time, updated: Date.now() },
|
||||
}))
|
||||
break
|
||||
}
|
||||
case "session.usage.updated": {
|
||||
const properties = event.properties as Pick<SessionInfo, "cost" | "tokens"> & { sessionID: string }
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
input.setStore("session", result.index, (session) => ({
|
||||
...session,
|
||||
cost: properties.cost,
|
||||
tokens: properties.tokens,
|
||||
}))
|
||||
break
|
||||
}
|
||||
// case "session.archived": {
|
||||
// const properties = event.properties as { sessionID: string }
|
||||
// const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
// if (!result.found) break
|
||||
// const info = input.store.session[result.index]
|
||||
// input.setStore(
|
||||
// "session",
|
||||
// produce((draft) => void draft.splice(result.index, 1)),
|
||||
// )
|
||||
// cleanupSessionCaches(input.setStore, properties.sessionID)
|
||||
// if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
// break
|
||||
// }
|
||||
case "session.moved": {
|
||||
const properties = event.properties as {
|
||||
sessionID: string
|
||||
location: { directory: string; workspaceID?: string }
|
||||
projectID?: string
|
||||
subpath?: string
|
||||
}
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
if (properties.location.directory === input.directory) {
|
||||
input.setStore("session", result.index, (session) => ({
|
||||
...session,
|
||||
projectID: properties.projectID ?? session.projectID,
|
||||
location: properties.location,
|
||||
subpath: properties.subpath,
|
||||
time: { ...session.time, updated: Date.now() },
|
||||
}))
|
||||
break
|
||||
}
|
||||
const info = input.store.session[result.index]
|
||||
input.setStore(
|
||||
"session",
|
||||
produce((draft) => void draft.splice(result.index, 1)),
|
||||
)
|
||||
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" }))
|
||||
break
|
||||
}
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||
input.setSessionTodo?.(props.sessionID, props.todos)
|
||||
break
|
||||
}
|
||||
case "session.status": {
|
||||
const props = event.properties as { sessionID: string; status: SessionStatus }
|
||||
input.setStore("session_status", props.sessionID, reconcile(props.status))
|
||||
break
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = clean((event.properties as { info: Message }).info)
|
||||
const messages = input.store.message[info.sessionID]
|
||||
if (!messages) {
|
||||
input.setStore("message", info.sessionID, [info])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(messages, messageKey(info), messageKey)
|
||||
if (result.found) {
|
||||
input.setStore("message", info.sessionID, result.index, reconcile(info))
|
||||
break
|
||||
}
|
||||
input.setStore(
|
||||
"message",
|
||||
info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.removed": {
|
||||
const props = event.properties as { sessionID: string; messageID: string }
|
||||
input.setStore(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[props.sessionID]
|
||||
if (messages) {
|
||||
const index = messages.findIndex((message) => message.id === props.messageID)
|
||||
if (index >= 0) messages.splice(index, 1)
|
||||
}
|
||||
const parts = draft.part[props.messageID]
|
||||
if (parts) {
|
||||
for (const part of parts) {
|
||||
delete draft.part_text_accum_delta[part.id]
|
||||
}
|
||||
}
|
||||
delete draft.part[props.messageID]
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const part = (event.properties as { part: Part }).part
|
||||
if (SKIP_PARTS.has(part.type)) break
|
||||
input.setStore(
|
||||
produce((draft) => {
|
||||
delete draft.part_text_accum_delta[part.id]
|
||||
}),
|
||||
)
|
||||
const parts = input.store.part[part.messageID]
|
||||
if (!parts) {
|
||||
input.setStore("part", part.messageID, [part])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(parts, part.id, (item) => item.id)
|
||||
if (result.found) {
|
||||
input.setStore("part", part.messageID, result.index, reconcile(part))
|
||||
break
|
||||
}
|
||||
input.setStore(
|
||||
"part",
|
||||
part.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, part)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.part.removed": {
|
||||
const props = event.properties as { messageID: string; partID: string }
|
||||
input.setStore(
|
||||
produce((draft) => {
|
||||
delete draft.part_text_accum_delta[props.partID]
|
||||
}),
|
||||
)
|
||||
const parts = input.store.part[props.messageID]
|
||||
if (!parts) break
|
||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||
if (result.found) {
|
||||
input.setStore(
|
||||
produce((draft) => {
|
||||
const list = draft.part[props.messageID]
|
||||
if (!list) return
|
||||
const next = Binary.search(list, props.partID, (part) => part.id)
|
||||
if (!next.found) return
|
||||
list.splice(next.index, 1)
|
||||
if (list.length === 0) delete draft.part[props.messageID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "message.part.delta": {
|
||||
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
|
||||
const parts = input.store.part[props.messageID]
|
||||
if (!parts) break
|
||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||
if (!result.found) break
|
||||
const field = props.field as keyof (typeof parts)[number]
|
||||
const current = parts[result.index]?.[field]
|
||||
input.setStore(
|
||||
"part_text_accum_delta",
|
||||
props.partID,
|
||||
(existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta,
|
||||
)
|
||||
input.setStore(
|
||||
"part",
|
||||
props.messageID,
|
||||
produce((draft) => {
|
||||
const part = draft[result.index]
|
||||
const field = props.field as keyof typeof part
|
||||
const existing = part[field] as string | undefined
|
||||
;(part[field] as string) = (existing ?? "") + props.delta
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "vcs.branch.updated": {
|
||||
const props = event.properties as { branch?: string }
|
||||
if (input.store.vcs?.branch === props.branch) break
|
||||
const next = { ...input.store.vcs, branch: props.branch }
|
||||
const properties = input.event.properties as { branch?: string }
|
||||
if (input.store.vcs?.branch === properties.branch) break
|
||||
const next = { ...input.store.vcs, branch: properties.branch }
|
||||
input.setStore("vcs", next)
|
||||
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
||||
input.vcsCache?.setStore("value", next)
|
||||
break
|
||||
}
|
||||
case "permission.asked": {
|
||||
const permission = event.properties as PermissionRequest
|
||||
const permissions = input.store.permission[permission.sessionID]
|
||||
if (!permissions) {
|
||||
input.setStore("permission", permission.sessionID, [permission])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(permissions, permission.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
input.setStore("permission", permission.sessionID, result.index, reconcile(permission))
|
||||
break
|
||||
}
|
||||
input.setStore(
|
||||
"permission",
|
||||
permission.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, permission)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "permission.replied": {
|
||||
const props = event.properties as { sessionID: string; requestID: string }
|
||||
const permissions = input.store.permission[props.sessionID]
|
||||
if (!permissions) break
|
||||
const result = Binary.search(permissions, props.requestID, (p) => p.id)
|
||||
if (!result.found) break
|
||||
input.setStore(
|
||||
"permission",
|
||||
props.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "lsp.updated": {
|
||||
case "lsp.updated":
|
||||
input.loadLsp()
|
||||
break
|
||||
}
|
||||
case "reference.updated": {
|
||||
case "reference.updated":
|
||||
input.loadReferences?.()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,146 +1,53 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
applyHomeSessionEvent,
|
||||
appendHomeSessionEvent,
|
||||
HOME_V2_SESSION_PAGE_LIMIT,
|
||||
loadHomeSessionIndex,
|
||||
homeSessionIndexSessions,
|
||||
homeSessionIndexRefresh,
|
||||
parseHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
} from "./home-session-index"
|
||||
|
||||
const session = (input: {
|
||||
id: string
|
||||
directory?: string
|
||||
parentID?: string
|
||||
archived?: number
|
||||
updated?: number
|
||||
}) => ({
|
||||
id: input.id,
|
||||
parentID: input.parentID,
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: input.updated ?? 1, archived: input.archived },
|
||||
title: input.id,
|
||||
location: { directory: input.directory ?? "/project" },
|
||||
})
|
||||
const session = (id: string, input: Partial<SessionInfo> = {}) =>
|
||||
({
|
||||
id,
|
||||
projectID: "project",
|
||||
title: id,
|
||||
time: { created: 1, updated: 1 },
|
||||
location: { directory: "/repo" },
|
||||
...input,
|
||||
}) as SessionInfo
|
||||
|
||||
describe("Home V2 session index", () => {
|
||||
test("loads the Home index with one global V2 request", async () => {
|
||||
const calls: unknown[] = []
|
||||
test("loads all pages", async () => {
|
||||
const first = Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session(`session-${index}`))
|
||||
const calls: Array<{ cursor?: string }> = []
|
||||
const result = await loadHomeSessionIndex(async (input) => {
|
||||
calls.push(input)
|
||||
return { data: [session({ id: "root" })], cursor: {} }
|
||||
if (!input.cursor) return { data: first, cursor: { next: "next" } }
|
||||
return { data: [session("last")], cursor: {} }
|
||||
})
|
||||
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }])
|
||||
expect(result).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
|
||||
expect(calls.map((call) => call.cursor)).toEqual([undefined, "next"])
|
||||
})
|
||||
|
||||
test("loads subsequent pages until the session index is complete", async () => {
|
||||
const calls: unknown[] = []
|
||||
const controller = new AbortController()
|
||||
const result = await loadHomeSessionIndex(
|
||||
async (input, options) => {
|
||||
calls.push({ input, signal: options.signal })
|
||||
if (!("cursor" in input)) {
|
||||
return {
|
||||
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session({ id: `page-1-${index}` })),
|
||||
cursor: { next: "next-page" },
|
||||
}
|
||||
}
|
||||
return { data: [session({ id: "page-2" })], cursor: {} }
|
||||
},
|
||||
0,
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
|
||||
expect(calls).toEqual([
|
||||
{ input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal },
|
||||
{
|
||||
input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" },
|
||||
signal: controller.signal,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("maps visible roots to Home session summaries", () => {
|
||||
const activeNull = {
|
||||
...session({ id: "active-null", updated: 20 }),
|
||||
time: { created: 1, updated: 20, archived: null },
|
||||
} as unknown as SessionInfo
|
||||
const result = parseHomeSessionIndex([
|
||||
session({ id: "root", updated: 30 }),
|
||||
activeNull,
|
||||
session({ id: "child", parentID: "root", updated: 40 }),
|
||||
session({ id: "archived", archived: 50, updated: 50 }),
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "root",
|
||||
location: { directory: "/project" },
|
||||
projectID: "project",
|
||||
title: "root",
|
||||
time: { created: 1, updated: 30 },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "active-null",
|
||||
time: { created: 1, updated: 20, archived: null },
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves the per-directory Home retention limit", () => {
|
||||
const now = 10 * 60 * 60 * 1000
|
||||
const sessions = Array.from({ length: 80 }, (_, index) => ({
|
||||
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
|
||||
location: { directory: index % 2 === 0 ? "/one" : "/two" },
|
||||
}))
|
||||
|
||||
const retained = retainHomeSessions(sessions, 10, now)
|
||||
expect(retained.filter((item) => item.location.directory === "/one")).toHaveLength(10)
|
||||
expect(retained.filter((item) => item.location.directory === "/two")).toHaveLength(10)
|
||||
})
|
||||
|
||||
test("replays session events over the loaded index", () => {
|
||||
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
||||
const created = { ...initial[0], id: "new", title: "new", time: { created: 2, updated: 2 } }
|
||||
|
||||
const afterCreate = applyHomeSessionEvent(initial, {
|
||||
type: "session.created",
|
||||
properties: { sessionID: created.id, info: created },
|
||||
})
|
||||
test("keeps only visible roots", () => {
|
||||
expect(
|
||||
applyHomeSessionEvent(afterCreate, {
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: initial[0]!.id, info: initial[0]! },
|
||||
}),
|
||||
).toEqual([created])
|
||||
parseHomeSessionIndex([
|
||||
session("root"),
|
||||
session("child", { parentID: "root" }),
|
||||
session("archived", { time: { created: 1, updated: 1, archived: 2 } }),
|
||||
]).map((item) => item.id),
|
||||
).toEqual(["root"])
|
||||
})
|
||||
|
||||
test("applies only events newer than the index baseline", () => {
|
||||
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
||||
const stale = { ...initial[0], title: "stale" }
|
||||
const current = { ...initial[0], title: "current" }
|
||||
const first = appendHomeSessionEvent(undefined, {
|
||||
type: "session.updated",
|
||||
properties: { sessionID: stale.id, info: stale },
|
||||
})
|
||||
const events = appendHomeSessionEvent(first, {
|
||||
type: "session.updated",
|
||||
properties: { sessionID: current.id, info: current },
|
||||
})
|
||||
|
||||
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
|
||||
})
|
||||
|
||||
test("refetches after reconnect", () => {
|
||||
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
|
||||
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
|
||||
test("preserves the per-directory retention limit", () => {
|
||||
const now = Date.now()
|
||||
const result = retainHomeSessions(
|
||||
[session("a", { time: { created: 1, updated: 1 } }), session("b", { time: { created: 2, updated: 2 } })],
|
||||
1,
|
||||
now,
|
||||
)
|
||||
expect(result.map((item) => item.id)).toEqual(["b"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
import type { Event } from "@/types"
|
||||
import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise"
|
||||
import type { QueryClient } from "@tanstack/solid-query"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
|
||||
|
||||
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
|
||||
|
||||
export type HomeSessionEvent = {
|
||||
type: "session.created" | "session.updated" | "session.deleted"
|
||||
properties: { sessionID: string; info?: SessionInfo }
|
||||
}
|
||||
export type HomeSessionEvents = {
|
||||
sequence: number
|
||||
entries: Array<{ sequence: number; event: HomeSessionEvent }>
|
||||
}
|
||||
export type HomeSessionIndex = {
|
||||
sessions: SessionInfo[]
|
||||
eventSequence: number
|
||||
}
|
||||
|
||||
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
|
||||
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
||||
|
||||
export async function loadHomeSessionIndex(
|
||||
list: (
|
||||
input: { limit: number; order: "desc"; cursor?: string },
|
||||
options: { signal?: AbortSignal },
|
||||
) => Promise<SessionsResponse>,
|
||||
eventSequence = 0,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const data: SessionInfo[] = []
|
||||
@@ -42,115 +25,28 @@ export async function loadHomeSessionIndex(
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
const page = response
|
||||
data.push(...page.data)
|
||||
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
||||
return { sessions: parseHomeSessionIndex(data), eventSequence }
|
||||
cursor = page.cursor.next
|
||||
data.push(...response.data)
|
||||
if (response.data.length < HOME_V2_SESSION_PAGE_LIMIT || !response.cursor.next) return parseHomeSessionIndex(data)
|
||||
cursor = response.cursor.next
|
||||
}
|
||||
}
|
||||
|
||||
export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) {
|
||||
const sequence = (current?.sequence ?? 0) + 1
|
||||
return {
|
||||
sequence,
|
||||
entries: [...(current?.entries ?? []), { sequence, event }],
|
||||
}
|
||||
}
|
||||
|
||||
export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents {
|
||||
return {
|
||||
sequence: current?.sequence ?? sequence,
|
||||
entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence),
|
||||
}
|
||||
}
|
||||
|
||||
export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
|
||||
if (!index) return []
|
||||
return (events?.entries ?? [])
|
||||
.filter((entry) => entry.sequence > index.eventSequence)
|
||||
.reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions)
|
||||
}
|
||||
|
||||
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
|
||||
if (event === "server.connected") return { connected: true, refetch: connected }
|
||||
return { connected, refetch: false }
|
||||
}
|
||||
|
||||
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
|
||||
const indexKey = homeSessionIndexKey(server)
|
||||
const eventsKey = homeSessionEventsKey(server)
|
||||
let connected = false
|
||||
|
||||
return {
|
||||
indexKey,
|
||||
eventsKey,
|
||||
eventSequence() {
|
||||
return queryClient.getQueryData<HomeSessionEvents>(eventsKey)?.sequence ?? 0
|
||||
},
|
||||
complete(sequence: number) {
|
||||
// Keep events received after the fetch began so its response cannot overwrite them.
|
||||
queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
|
||||
},
|
||||
sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
|
||||
return homeSessionIndexSessions(index, events)
|
||||
},
|
||||
apply(event: HomeSessionEvent) {
|
||||
if (!queryClient.getQueryState(indexKey)) return
|
||||
const next = appendHomeSessionEvent(queryClient.getQueryData<HomeSessionEvents>(eventsKey), event)
|
||||
if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) {
|
||||
queryClient.setQueryData(eventsKey, next)
|
||||
return
|
||||
}
|
||||
|
||||
const index = queryClient.getQueryData<HomeSessionIndex>(indexKey)
|
||||
if (index) {
|
||||
queryClient.setQueryData<HomeSessionIndex>(indexKey, {
|
||||
sessions: homeSessionIndexSessions(index, next),
|
||||
eventSequence: next.sequence,
|
||||
})
|
||||
}
|
||||
queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
|
||||
},
|
||||
refresh(event: Event["type"]) {
|
||||
const result = homeSessionIndexRefresh(event, connected)
|
||||
connected = result.connected
|
||||
if (!result.refetch) return
|
||||
void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(v2): This deliberately dumb full-table scan is necessary because the
|
||||
// current V2 API orders by creation time and cannot filter roots, archives, or
|
||||
// multiple directories. A bounded page could omit an old session updated today.
|
||||
// Once released, use client.v2.project.list() and client.v2.session.list({
|
||||
// parentID: null, order: "desc" }) and replace this full-table scan.
|
||||
export function parseHomeSessionIndex(sessions: SessionInfo[]): SessionInfo[] {
|
||||
return sessions.flatMap((item) => {
|
||||
if (item.parentID || typeof item.time.archived === "number") return []
|
||||
return [item]
|
||||
})
|
||||
// The V2 API cannot yet filter roots, archives, or several directories, so Home
|
||||
// seeds createData from a full scan and derives its visible index there.
|
||||
export function parseHomeSessionIndex(sessions: SessionInfo[]) {
|
||||
return sessions.filter((session) => !session.parentID && typeof session.time.archived !== "number")
|
||||
}
|
||||
|
||||
export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) {
|
||||
const grouped = Map.groupBy(sessions, (session) => pathKey(session.location.directory))
|
||||
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
|
||||
}
|
||||
|
||||
export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessionEvent) {
|
||||
const info = event.properties.info
|
||||
const index = sessions.findIndex((session) => session.id === (info?.id ?? event.properties.sessionID))
|
||||
if (event.type === "session.deleted") {
|
||||
if (index === -1) return sessions
|
||||
return sessions.toSpliced(index, 1)
|
||||
}
|
||||
if (!info) return sessions
|
||||
if (info.parentID || typeof info.time.archived === "number") {
|
||||
if (index === -1) return sessions
|
||||
return sessions.toSpliced(index, 1)
|
||||
}
|
||||
if (event.type !== "session.created" && event.type !== "session.updated") return sessions
|
||||
if (index === -1) return [...sessions, info]
|
||||
return sessions.with(index, info)
|
||||
return [...Map.groupBy(sessions, (session) => pathKey(session.location.directory)).values()].flatMap((items) => {
|
||||
const sorted = items.toSorted((a, b) => {
|
||||
const updated = (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)
|
||||
return updated || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
|
||||
})
|
||||
const recent = sorted
|
||||
.slice(limit)
|
||||
.filter((session) => (session.time.updated ?? session.time.created) > now - SESSION_RECENT_WINDOW)
|
||||
.slice(0, SESSION_RECENT_LIMIT)
|
||||
return [...sorted.slice(0, limit), ...recent]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, Todo } from "@/types"
|
||||
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
const msg = (id: string, sessionID: string) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "assistant",
|
||||
model: { providerID: "openai", modelID: "gpt" },
|
||||
}) as Message
|
||||
|
||||
const part = (id: string, sessionID: string, messageID: string) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: id,
|
||||
}) as Part
|
||||
|
||||
describe("app session cache", () => {
|
||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
session_message: Record<string, never[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
form: Record<string, FormInfo[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||
session_diff: { ses_1: [] },
|
||||
todo: { ses_1: [] as Todo[] },
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
form: { ses_1: [] as FormInfo[] },
|
||||
part_text_accum_delta: { prt_1: "streamed text" },
|
||||
}
|
||||
|
||||
dropSessionCaches(store, ["ses_1"])
|
||||
|
||||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part.msg_1).toBeUndefined()
|
||||
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
|
||||
expect(store.todo.ses_1).toBeUndefined()
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
expect(store.permission.ses_1).toBeUndefined()
|
||||
expect(store.form.ses_1).toBeUndefined()
|
||||
})
|
||||
|
||||
test("dropSessionCaches clears message-backed parts", () => {
|
||||
const m = msg("msg_1", "ses_1")
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
session_message: Record<string, never[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
form: Record<string, FormInfo[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
message: { ses_1: [m] },
|
||||
session_message: {},
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
form: {},
|
||||
part_text_accum_delta: {},
|
||||
}
|
||||
|
||||
dropSessionCaches(store, ["ses_1"])
|
||||
|
||||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part[m.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("pickSessionCacheEvictions preserves requested sessions", () => {
|
||||
const seen = new Set(["ses_1", "ses_2", "ses_3"])
|
||||
|
||||
const stale = pickSessionCacheEvictions({
|
||||
seen,
|
||||
keep: "ses_4",
|
||||
limit: 2,
|
||||
preserve: ["ses_1"],
|
||||
})
|
||||
|
||||
expect(stale).toEqual(["ses_2", "ses_3"])
|
||||
expect([...seen]).toEqual(["ses_1", "ses_4"])
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { Message, Part, Todo } from "@/types"
|
||||
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
export const SESSION_CACHE_LIMIT = 40
|
||||
|
||||
type SessionCache = {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
session_message: Record<string, SessionMessageInfo[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
form?: Record<string, FormInfo[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
|
||||
const stale = new Set(Array.from(sessionIDs).filter(Boolean))
|
||||
if (stale.size === 0) return
|
||||
|
||||
for (const key of Object.keys(store.part)) {
|
||||
const parts = store.part[key]
|
||||
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
|
||||
for (const part of parts) {
|
||||
delete store.part_text_accum_delta[part.id]
|
||||
}
|
||||
delete store.part[key]
|
||||
}
|
||||
|
||||
for (const sessionID of stale) {
|
||||
delete store.message[sessionID]
|
||||
delete store.todo[sessionID]
|
||||
delete store.session_message[sessionID]
|
||||
delete store.session_diff[sessionID]
|
||||
delete store.session_status[sessionID]
|
||||
delete store.permission[sessionID]
|
||||
if (store.form) delete store.form[sessionID]
|
||||
}
|
||||
}
|
||||
|
||||
export function pickSessionCacheEvictions(input: {
|
||||
seen: Set<string>
|
||||
keep: string
|
||||
limit: number
|
||||
preserve?: Iterable<string>
|
||||
}) {
|
||||
const stale: string[] = []
|
||||
const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])])
|
||||
if (input.seen.has(input.keep)) input.seen.delete(input.keep)
|
||||
input.seen.add(input.keep)
|
||||
for (const id of input.seen) {
|
||||
if (input.seen.size - stale.length <= input.limit) break
|
||||
if (keep.has(id)) continue
|
||||
stale.push(id)
|
||||
}
|
||||
for (const id of stale) {
|
||||
input.seen.delete(id)
|
||||
}
|
||||
return stale
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/promise"
|
||||
|
||||
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
|
||||
const result = await input.api.list({
|
||||
directory: input.directory,
|
||||
parentID: null,
|
||||
limit: input.limit,
|
||||
order: "desc",
|
||||
})
|
||||
return {
|
||||
data: result.data,
|
||||
limit: input.limit,
|
||||
limited: true,
|
||||
} as const
|
||||
}
|
||||
|
||||
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
|
||||
if (!input.limited) return input.count
|
||||
if (input.count < input.limit) return input.count
|
||||
return input.count + 1
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { trimSessions } from "./session-trim"
|
||||
|
||||
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
|
||||
({
|
||||
id: input.id,
|
||||
parentID: input.parentID,
|
||||
time: {
|
||||
created: input.created,
|
||||
updated: input.updated,
|
||||
archived: input.archived,
|
||||
},
|
||||
}) as SessionInfo
|
||||
|
||||
describe("trimSessions", () => {
|
||||
test("keeps base roots and recent roots beyond the limit", () => {
|
||||
const now = 1_000_000
|
||||
const list = [
|
||||
session({ id: "a", created: now - 100_000 }),
|
||||
session({ id: "b", created: now - 90_000 }),
|
||||
session({ id: "c", created: now - 80_000 }),
|
||||
session({ id: "d", created: now - 70_000, updated: now - 1_000 }),
|
||||
session({ id: "e", created: now - 60_000, archived: now - 10 }),
|
||||
]
|
||||
|
||||
const result = trimSessions(list, { limit: 2, permission: {}, now })
|
||||
expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"])
|
||||
})
|
||||
|
||||
test("keeps children when root is kept, permission exists, or child is recent", () => {
|
||||
const now = 1_000_000
|
||||
const list = [
|
||||
session({ id: "root-1", created: now - 1000 }),
|
||||
session({ id: "root-2", created: now - 2000 }),
|
||||
session({ id: "z-root", created: now - 30_000_000 }),
|
||||
session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }),
|
||||
session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }),
|
||||
session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }),
|
||||
session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }),
|
||||
]
|
||||
|
||||
const result = trimSessions(list, {
|
||||
limit: 2,
|
||||
permission: {
|
||||
"child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
|
||||
},
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result.map((x) => x.id)).toEqual([
|
||||
"child-kept-by-permission",
|
||||
"child-kept-by-recency",
|
||||
"child-kept-by-root",
|
||||
"root-1",
|
||||
"root-2",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { PermissionRequest } from "@opencode-ai/client/promise"
|
||||
import { cmp } from "./utils"
|
||||
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
|
||||
|
||||
export function sessionUpdatedAt(session: SessionInfo) {
|
||||
return session.time.updated ?? session.time.created
|
||||
}
|
||||
|
||||
export function compareSessionRecent(a: SessionInfo, b: SessionInfo) {
|
||||
const aUpdated = sessionUpdatedAt(a)
|
||||
const bUpdated = sessionUpdatedAt(b)
|
||||
if (aUpdated !== bUpdated) return bUpdated - aUpdated
|
||||
return cmp(a.id, b.id)
|
||||
}
|
||||
|
||||
export function takeRecentSessions(sessions: SessionInfo[], limit: number, cutoff: number) {
|
||||
if (limit <= 0) return [] as SessionInfo[]
|
||||
const selected: SessionInfo[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const session of sessions) {
|
||||
if (!session?.id) continue
|
||||
if (seen.has(session.id)) continue
|
||||
seen.add(session.id)
|
||||
if (sessionUpdatedAt(session) <= cutoff) continue
|
||||
const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
|
||||
if (index === -1) selected.push(session)
|
||||
if (index !== -1) selected.splice(index, 0, session)
|
||||
if (selected.length > limit) selected.pop()
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
export function trimSessions(
|
||||
input: SessionInfo[],
|
||||
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
|
||||
) {
|
||||
const limit = Math.max(0, options.limit)
|
||||
const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
|
||||
const all = input
|
||||
.filter((s) => !!s?.id)
|
||||
.filter((s) => !s.time?.archived)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
const roots = all.filter((s) => !s.parentID)
|
||||
roots.sort(compareSessionRecent)
|
||||
const children = all.filter((s) => !!s.parentID)
|
||||
const base = roots.slice(0, limit)
|
||||
const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
|
||||
const keepRoots = [...base, ...recent]
|
||||
const keepRootIds = new Set(keepRoots.map((s) => s.id))
|
||||
const keepChildren = children.filter((s) => {
|
||||
if (s.parentID && keepRootIds.has(s.parentID)) return true
|
||||
const perms = options.permission[s.id] ?? []
|
||||
if (perms.length > 0) return true
|
||||
return sessionUpdatedAt(s) > cutoff
|
||||
})
|
||||
return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
import type { Agent, Config, LspStatus, Message, Part, Path, Todo, VcsInfo } from "@/types"
|
||||
import type {
|
||||
FileDiffInfo,
|
||||
PermissionRequest,
|
||||
ReferenceInfo,
|
||||
SessionInfo,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { Agent, Config, LspStatus, Path, VcsInfo } from "@/types"
|
||||
import type { ReferenceInfo } from "@opencode-ai/client/promise"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { CommandInfo, McpResource, McpServer } from "@opencode-ai/client/promise"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
|
||||
@@ -34,21 +28,6 @@ export type State = {
|
||||
provider: NormalizedProviderListResponse
|
||||
config: Config
|
||||
path: Path
|
||||
session: SessionInfo[]
|
||||
sessionTotal: number
|
||||
session_status: {
|
||||
[sessionID: string]: SessionStatus
|
||||
}
|
||||
session_working(id: string): boolean
|
||||
session_diff: {
|
||||
[sessionID: string]: FileDiffInfo[]
|
||||
}
|
||||
todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
permission: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
}
|
||||
mcp_ready: boolean
|
||||
mcp: {
|
||||
[name: string]: McpServer["status"]
|
||||
@@ -59,19 +38,6 @@ export type State = {
|
||||
lsp_ready: boolean
|
||||
lsp: LspStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
limit: number
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
}
|
||||
session_message: {
|
||||
[sessionID: string]: SessionMessageInfo[]
|
||||
}
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
part_text_accum_delta: {
|
||||
[partID: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
export type VcsCache = {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
AgentListOutput,
|
||||
ModelDefaultOutput,
|
||||
ModelListOutput,
|
||||
ProviderListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
@@ -75,7 +74,6 @@ describe("normalizeProviderList", () => {
|
||||
limit: { context: 1, output: 1 },
|
||||
},
|
||||
] as ModelListOutput["data"],
|
||||
{ id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"],
|
||||
)
|
||||
|
||||
expect(result.connected).toEqual(["openai"])
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
AgentListOutput,
|
||||
ModelDefaultOutput,
|
||||
ModelListOutput,
|
||||
ProviderListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
@@ -38,7 +37,6 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag
|
||||
export function normalizeProviderList(
|
||||
providers: ProviderListOutput["data"] | ProviderListResponse,
|
||||
models?: ModelListOutput["data"],
|
||||
defaultModel?: ModelDefaultOutput["data"],
|
||||
): NormalizedProviderListResponse {
|
||||
if (!Array.isArray(providers)) {
|
||||
return providers
|
||||
@@ -113,10 +111,7 @@ export function normalizeProviderList(
|
||||
connected: providers.map((provider) => provider.id),
|
||||
default: Object.fromEntries(
|
||||
providers.flatMap((provider) => {
|
||||
const model =
|
||||
defaultModel?.providerID === provider.id
|
||||
? defaultModel
|
||||
: models?.find((item) => item.providerID === provider.id && item.status !== "deprecated")
|
||||
const model = models?.find((item) => item.providerID === provider.id && item.status !== "deprecated")
|
||||
return model ? [[provider.id, model.id]] : []
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useServerHealth } from "@/utils/server-health"
|
||||
import { createServerSdkContext } from "./server-sdk"
|
||||
import { createServerSyncContext } from "./server-sync"
|
||||
import { getOwner } from "solid-js/web"
|
||||
import { createData } from "@opencode-ai/client/solid"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerPermissionState } from "./permission"
|
||||
import { createServerNotificationState } from "./notification"
|
||||
@@ -99,9 +100,15 @@ function createServerController(
|
||||
) {
|
||||
const connKey = ServerConnection.key(conn)
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
const sync = createServerSyncContext(sdk)
|
||||
const permission = createServerPermissionState({ sdk, sync })
|
||||
const notification = createServerNotificationState({ sdk, sync, key: connKey })
|
||||
const data = createData({
|
||||
api: () => sdk.api,
|
||||
event: sdk.event,
|
||||
connection: sdk.connection,
|
||||
directory: "",
|
||||
})
|
||||
const sync = createServerSyncContext(sdk, data)
|
||||
const permission = createServerPermissionState({ sdk, sync, data })
|
||||
const notification = createServerNotificationState({ sdk, data, key: connKey })
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
||||
@@ -134,6 +141,7 @@ function createServerController(
|
||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||
|
||||
return {
|
||||
data,
|
||||
sdk,
|
||||
sync,
|
||||
isLocal,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } fr
|
||||
import { useLocation } from "@solidjs/router"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { ServerConnection, useServers } from "./servers"
|
||||
import { usePlatform } from "./platform"
|
||||
import type { Project } from "@/types"
|
||||
@@ -68,7 +67,6 @@ type SessionView = {
|
||||
reviewFile?: string
|
||||
pendingMessage?: string
|
||||
pendingMessageAt?: number
|
||||
todoCollapsed?: boolean
|
||||
}
|
||||
|
||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||
@@ -150,7 +148,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
name: "Layout",
|
||||
gate: false,
|
||||
init: () => {
|
||||
// const serverSync = useServerSync()
|
||||
const servers = useServers()
|
||||
const platform = usePlatform()
|
||||
|
||||
@@ -706,18 +703,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
setScroll(tab: string, pos: SessionScroll) {
|
||||
scroll.setScroll(key(), tab, pos)
|
||||
},
|
||||
todoCollapsed: {
|
||||
get: () => s().todoCollapsed ?? false,
|
||||
set(collapsed: boolean) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed })
|
||||
} else {
|
||||
setStore("sessionView", session, "todoCollapsed", collapsed)
|
||||
}
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
opened: terminalOpened,
|
||||
open() {
|
||||
|
||||
@@ -9,8 +9,9 @@ import { useProviders } from "@/hooks/use-providers"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { hasCustomAgent, resolveAgent } from "./local-agent"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
import { useWorkspaceLocation } from "./location"
|
||||
import { useData } from "./server"
|
||||
import { normalizeAgentList } from "./global-sync/utils"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
|
||||
@@ -59,15 +60,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const models = useModels()
|
||||
const settings = useSettings()
|
||||
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||
const list = createMemo(() =>
|
||||
normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []).filter(
|
||||
(item) => item.mode !== "subagent" && !item.hidden,
|
||||
),
|
||||
)
|
||||
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
@@ -148,14 +153,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
setStore("promoting", undefined)
|
||||
})
|
||||
|
||||
const configuredModel = () => {
|
||||
const configured = sync().data.config.model
|
||||
if (!configured) return
|
||||
const [providerID, modelID] = configured.split("/")
|
||||
const model = { providerID, modelID }
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
|
||||
const recentModel = () => {
|
||||
for (const item of models.recent.list()) {
|
||||
if (validModel(item)) return item
|
||||
@@ -163,14 +160,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const defaultModel = () => {
|
||||
const defaults = providers.default()
|
||||
for (const provider of providers.connected()) {
|
||||
const configured = defaults[provider.id]
|
||||
if (configured) {
|
||||
const model = { providerID: provider.id, modelID: configured }
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
|
||||
const first = Object.values(provider.models)[0]
|
||||
if (!first) continue
|
||||
const model = { providerID: provider.id, modelID: first.id }
|
||||
@@ -178,7 +168,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = createMemo<ModelKey | undefined>(() => configuredModel() ?? recentModel() ?? defaultModel())
|
||||
const fallback = createMemo<ModelKey | undefined>(() => recentModel() ?? defaultModel())
|
||||
|
||||
const agent = {
|
||||
list,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise"
|
||||
import { type Accessor, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { type LocationContext, useServerSDK } from "./server-sdk"
|
||||
import { useData } from "./server"
|
||||
export type { LocationContext } from "./server-sdk"
|
||||
|
||||
export type WorkspaceLocation = LocationContext & {
|
||||
readonly ref: LocationRef
|
||||
readonly current: LocationGetOutput | undefined
|
||||
readonly error: { readonly location: LocationRef; readonly cause: unknown } | undefined
|
||||
}
|
||||
|
||||
const context = createSimpleContext({
|
||||
name: "Location",
|
||||
init: (props: {
|
||||
directory: string | Accessor<string>
|
||||
workspaceID?: string | Accessor<string | undefined>
|
||||
}) => {
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const ref = createMemo(() => ({
|
||||
directory: typeof props.directory === "function" ? props.directory() : props.directory,
|
||||
workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID,
|
||||
}))
|
||||
const current = createMemo(() => data.location.info(ref()))
|
||||
const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>()
|
||||
let generation = 0
|
||||
|
||||
createEffect(() => {
|
||||
const location = ref()
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
const attempt = ++generation
|
||||
setError(undefined)
|
||||
void data.location.sync(location).catch((cause) => {
|
||||
const latest = ref()
|
||||
if (
|
||||
generation !== attempt ||
|
||||
latest.directory !== location.directory ||
|
||||
latest.workspaceID !== location.workspaceID
|
||||
)
|
||||
return
|
||||
setError({ location, cause })
|
||||
})
|
||||
})
|
||||
|
||||
const location = createMemo(() =>
|
||||
serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory),
|
||||
)
|
||||
return createMemo<WorkspaceLocation>(() => ({
|
||||
...location(),
|
||||
ref: ref(),
|
||||
current: current(),
|
||||
error: error(),
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
export const useWorkspaceLocation: () => Accessor<WorkspaceLocation> = context.use
|
||||
export const LocationProvider = context.provider
|
||||
@@ -1,14 +1,45 @@
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
export function useMcpToggle() {
|
||||
const sync = useSync()
|
||||
export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess?: () => unknown) {
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const location = () => {
|
||||
const value = directory ? directory() : data.location.default().directory
|
||||
return value ? { directory: value } : undefined
|
||||
}
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: sync().mcp.toggle,
|
||||
mutationFn: async (name: string) => {
|
||||
const ref = location()
|
||||
const server = (await serverSDK.api.mcp.list({ location: ref })).data.find((item) => item.name === name)
|
||||
if (!server || server.status.status === "pending") return
|
||||
if (server.status.status === "connected") {
|
||||
await serverSDK.api.mcp.disconnect({ server: name, location: ref })
|
||||
} else if (server.status.status === "needs_auth" && server.integrationID) {
|
||||
const integration = await serverSDK.api.integration.get({ integrationID: server.integrationID, location: ref })
|
||||
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length)
|
||||
if (!method || method.type !== "oauth") throw new Error(`MCP server ${name} requires an interactive authentication form`)
|
||||
const attempt = await serverSDK.api.integration.oauth.connect({
|
||||
integrationID: server.integrationID,
|
||||
methodID: method.id,
|
||||
location: ref,
|
||||
})
|
||||
platform.openExternal(attempt.data.url)
|
||||
} else {
|
||||
await serverSDK.api.mcp.connect({ server: name, location: ref })
|
||||
}
|
||||
data.location.mcp.server.invalidate(ref)
|
||||
data.location.mcp.resource.invalidate(ref)
|
||||
await Promise.all([data.location.mcp.server.sync(ref), data.location.mcp.resource.sync(ref), onSuccess?.()])
|
||||
},
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createStore, reconcile } from "solid-js/store"
|
||||
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { ServerSDK } from "./server-sdk"
|
||||
import type { ServerSync } from "./server-sync"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
@@ -109,7 +109,7 @@ function buildNotificationIndex(list: Notification[]) {
|
||||
return index
|
||||
}
|
||||
|
||||
export function createServerNotificationState(input: { sdk: ServerSDK; sync: ServerSync; key: ServerConnection.Key }) {
|
||||
export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
@@ -197,14 +197,13 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
||||
})
|
||||
}
|
||||
|
||||
const lookup = async (directory: string, sessionID?: string) => {
|
||||
const lookup = async (sessionID?: string) => {
|
||||
if (!sessionID) return undefined
|
||||
const sync = input.sync.ensureDirSyncContext(directory)
|
||||
const session = sync.session.get(sessionID)
|
||||
const session = input.data.session.get(sessionID)
|
||||
if (session) return session
|
||||
return sync.session
|
||||
return input.data.session
|
||||
.sync(sessionID)
|
||||
.then(() => sync.session.get(sessionID))
|
||||
.then(() => input.data.session.get(sessionID))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
@@ -219,7 +218,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
||||
|
||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
void lookup(sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (!session) return
|
||||
if (session.parentID) return
|
||||
@@ -251,7 +250,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
||||
time: number,
|
||||
) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
void lookup(sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (session?.parentID) return
|
||||
|
||||
@@ -278,7 +277,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = input.sdk.event.listen((e) => {
|
||||
const unsub = input.sdk.eventByDir.listen((e) => {
|
||||
const event = e.details
|
||||
if (
|
||||
event.type !== "session.execution.succeeded" &&
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("autoRespondsPermission", () => {
|
||||
expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true)
|
||||
})
|
||||
|
||||
test("defaults to requiring approval when no lineage override exists", () => {
|
||||
test("defaults to requiring approval when no family override exists", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
|
||||
const autoAccept = {
|
||||
other: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { PermissionRequest } from "@opencode-ai/client/promise"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { ServerSDK } from "@/context/server-sdk"
|
||||
import type { ServerSync } from "./server-sync"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useGlobal } from "./global"
|
||||
@@ -52,9 +53,9 @@ function hasPermissionPromptRules(permission: unknown) {
|
||||
return Object.values(config).some(isNonAllowRule)
|
||||
}
|
||||
|
||||
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
|
||||
type PermissionEvent = Parameters<Parameters<ServerSDK["eventByDir"]["listen"]>[0]>[0]
|
||||
|
||||
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
||||
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
|
||||
@@ -141,10 +142,8 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
||||
})
|
||||
}
|
||||
|
||||
function sessions(directory?: string) {
|
||||
const info = Object.values(input.sync.session.data.info).filter((session) => !!session)
|
||||
if (!directory) return info
|
||||
return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session]
|
||||
function sessions(_directory?: string) {
|
||||
return input.data.session.list()
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
@@ -160,16 +159,21 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
||||
}
|
||||
|
||||
function isPending(permission: PermissionRequest) {
|
||||
const pending = input.sync.session.data.permission[permission.sessionID]
|
||||
const pending = input.data.session.permission.list(permission.sessionID)
|
||||
return pending === undefined || pending.some((item) => item.id === permission.id)
|
||||
}
|
||||
|
||||
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
|
||||
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
|
||||
if (override !== undefined) return override
|
||||
if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
|
||||
const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined)
|
||||
if (meta.disposed || !lineage) return false
|
||||
const loaded = new Set<string>()
|
||||
while (!loaded.has(input.data.session.root(permission.sessionID))) {
|
||||
const root = input.data.session.root(permission.sessionID)
|
||||
loaded.add(root)
|
||||
if (input.data.session.get(root)) break
|
||||
await input.data.session.sync(root).catch(() => undefined)
|
||||
}
|
||||
if (meta.disposed || !input.data.session.get(permission.sessionID)) return false
|
||||
return shouldAutoRespond(permission, directory)
|
||||
}
|
||||
|
||||
@@ -197,7 +201,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
||||
void respondPending(event.properties, event.current?.location?.directory)
|
||||
}
|
||||
|
||||
const unsubscribe = input.sdk.event.listen((event) => {
|
||||
const unsubscribe = input.sdk.eventByDir.listen((event) => {
|
||||
if (ready()) {
|
||||
handlePermission(event)
|
||||
return
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createMemo, createResource, createRoot, getOwner, onCleanup } from "sol
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { ServerConnection } from "./servers"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useWorkspaceLocation } from "./location"
|
||||
import { useTabs, type Tab } from "./tabs"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import {
|
||||
@@ -75,7 +75,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams<{ serverKey?: string; id?: string }>()
|
||||
const sdk = useSDK()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const serverSDK = useServerSDK()
|
||||
const tabs = useTabs()
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { type DirectorySDK, useServerSDK } from "./server-sdk"
|
||||
export type { DirectorySDK } from "./server-sdk"
|
||||
|
||||
const context = createSimpleContext({
|
||||
name: "SDK",
|
||||
// Resolves the directory-scoped SDK reactively from the (possibly changing) server.
|
||||
init: (props: { directory: string | Accessor<string> }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
return createMemo(() => {
|
||||
const directory = typeof props.directory === "function" ? props.directory() : props.directory
|
||||
return serverSDK.ensureDirSdkContext(directory)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const useSDK: () => Accessor<DirectorySDK> = context.use
|
||||
export const SDKProvider = context.provider
|
||||
@@ -16,9 +16,9 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||
}
|
||||
|
||||
type ServerEventMap = { [Type in ServerEvent["type"]]: Extract<ServerEvent, { type: Type }> }
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<ServerEventMap>>
|
||||
type ServerLocationEventEmitter = ReturnType<typeof createGlobalEmitter<{ [directory: string]: ServerEvent }>>
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||
type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
type CurrentEventEmitter = ReturnType<typeof createGlobalEmitter<CurrentEventMap>>
|
||||
export type ServerConnectionStatus = ClientConnectionStatus
|
||||
type ServerSDKBase = {
|
||||
server: ServerConnection.Any
|
||||
@@ -30,29 +30,28 @@ type ServerSDKBase = {
|
||||
attempt: Accessor<number>
|
||||
error: Accessor<string | undefined>
|
||||
}
|
||||
event: {
|
||||
eventByDir: {
|
||||
on: ServerEventEmitter["on"]
|
||||
listen: ServerEventEmitter["listen"]
|
||||
location: {
|
||||
on: ServerLocationEventEmitter["on"]
|
||||
}
|
||||
}
|
||||
event: {
|
||||
on: CurrentEventEmitter["on"]
|
||||
listen: CurrentEventEmitter["listen"]
|
||||
}
|
||||
}
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const emitter = createGlobalEmitter<ServerEventMap>()
|
||||
const locations = createGlobalEmitter<{ [directory: string]: ServerEvent }>()
|
||||
const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>()
|
||||
const emitter = createGlobalEmitter<CurrentEventMap>()
|
||||
|
||||
const connection = createClientConnection(api, {
|
||||
flushInterval: 16,
|
||||
pageLifecycle: true,
|
||||
onEvent(event) {
|
||||
const adapted = adaptServerEvent(event)
|
||||
emitter.emit(adapted.type, adapted)
|
||||
const directory = event.location?.directory
|
||||
if (directory) locations.emit(directory, adapted)
|
||||
emitter.emit(event.type, event)
|
||||
dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event))
|
||||
},
|
||||
log: {
|
||||
info(message, data) {
|
||||
@@ -63,8 +62,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
dirEmitter.clear()
|
||||
emitter.clear()
|
||||
locations.clear()
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -73,12 +72,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
url: server.http.url,
|
||||
api,
|
||||
connection,
|
||||
eventByDir: {
|
||||
on: dirEmitter.on.bind(dirEmitter),
|
||||
listen: dirEmitter.listen.bind(dirEmitter),
|
||||
},
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
listen: emitter.listen.bind(emitter),
|
||||
location: {
|
||||
on: locations.on.bind(locations),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -103,29 +103,21 @@ type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
||||
}
|
||||
|
||||
export type DirectorySDK = {
|
||||
scope: ServerScope
|
||||
export type LocationContext = {
|
||||
directory: string
|
||||
api: ServerApi
|
||||
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): LocationContext {
|
||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||
|
||||
const unsub = serverSDK.event.location.on(directory, (event) => {
|
||||
const unsub = serverSDK.eventByDir.on(directory, (event) => {
|
||||
emitter.emit(event.type, event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
return {
|
||||
scope: serverSDK.scope,
|
||||
directory,
|
||||
api: serverSDK.api,
|
||||
event: emitter,
|
||||
get url() {
|
||||
return serverSDK.url
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { createV2SessionReducer } from "./server-session-v2-reducer"
|
||||
|
||||
const event = (input: object) => input as OpenCodeEvent
|
||||
const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
|
||||
|
||||
describe("v2 session reducer", () => {
|
||||
test("moves a repeated inbox payload to the current event position", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
const result = reducer.reduce(
|
||||
[
|
||||
{ id: "msg_user", type: "user", text: "local", time: { created: 0 } },
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } },
|
||||
],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.inbox.enqueued",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inboxID: "msg_user",
|
||||
item: { type: "user", delivery: "steer", payload: { text: "durable" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result?.messages).toEqual([
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } },
|
||||
{ id: "msg_user", type: "user", text: "durable", time: { created: 1 } },
|
||||
])
|
||||
expect(result?.touched).toEqual(["msg_user"])
|
||||
})
|
||||
|
||||
test("projects promoted input and streaming assistant content", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
let messages: SessionMessageInfo[] = []
|
||||
const apply = (input: object) => {
|
||||
const result = reducer.reduce(messages, event(input))
|
||||
if (result) messages = result.messages
|
||||
return result
|
||||
}
|
||||
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.inbox.enqueued",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inboxID: "msg_user",
|
||||
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_step",
|
||||
type: "session.step.started",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_text_start",
|
||||
type: "session.text.started",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0 },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_text_delta",
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hel" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_text_end",
|
||||
type: "session.text.ended",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, text: "hello" },
|
||||
})
|
||||
|
||||
expect(messages[0]).toMatchObject({ id: "msg_user", type: "user", text: "hello" })
|
||||
expect(messages[1]).toMatchObject({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("prefers durable selection predecessors and derives them for older events", () => {
|
||||
const source: SessionMessageInfo[] = [
|
||||
{ id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_previous_model",
|
||||
type: "model-switched",
|
||||
model: { id: "old", providerID: "provider" },
|
||||
time: { created: 1 },
|
||||
},
|
||||
]
|
||||
const reducer = createV2SessionReducer()
|
||||
|
||||
const agent = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_agent",
|
||||
type: "session.agent.selected",
|
||||
data: { sessionID: "ses_1", agent: "plan", previous: "review" },
|
||||
}),
|
||||
)
|
||||
const model = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_model",
|
||||
type: "session.model.selected",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
model: { id: "new", providerID: "provider" },
|
||||
previous: { id: "durable", providerID: "provider" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const legacyAgent = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_legacy_agent",
|
||||
type: "session.agent.selected",
|
||||
data: { sessionID: "ses_1", agent: "plan" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" })
|
||||
expect(model?.messages.at(-1)).toMatchObject({
|
||||
type: "model-switched",
|
||||
model: { id: "new" },
|
||||
previous: { id: "durable" },
|
||||
})
|
||||
expect(legacyAgent?.messages.at(-1)).toMatchObject({
|
||||
type: "agent-switched",
|
||||
agent: "plan",
|
||||
previous: "build",
|
||||
})
|
||||
})
|
||||
|
||||
test("folds tool, retry, and completion events", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
let messages: SessionMessageInfo[] = []
|
||||
const apply = (input: object) => {
|
||||
const result = reducer.reduce(messages, event(input))
|
||||
if (result) messages = result.messages
|
||||
}
|
||||
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_step",
|
||||
type: "session.step.started",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_start",
|
||||
type: "session.tool.input.started",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", name: "bash" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_delta",
|
||||
type: "session.tool.input.delta",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", delta: "{}" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_called",
|
||||
type: "session.tool.called",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", input: {}, executed: true },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_success",
|
||||
type: "session.tool.success",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
id: "call_1",
|
||||
metadata: {},
|
||||
content: [{ type: "text", text: "done" }],
|
||||
executed: true,
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_retry",
|
||||
type: "session.retry.scheduled",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
attempt: 2,
|
||||
at: 10,
|
||||
error: { type: "ProviderError", message: "retry" },
|
||||
},
|
||||
})
|
||||
apply({ ...base, id: "evt_done", type: "session.execution.succeeded", data: { sessionID: "ses_1" } })
|
||||
|
||||
expect(messages[0]).toMatchObject({
|
||||
type: "assistant",
|
||||
retry: undefined,
|
||||
content: [{ type: "tool", id: "call_1", state: { status: "completed", content: [{ text: "done" }] } }],
|
||||
})
|
||||
})
|
||||
|
||||
test("requests hydration when promotion admission was missed", () => {
|
||||
const result = createV2SessionReducer().reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||
})
|
||||
|
||||
test("projects rendered instruction updates", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
const result = reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_instructions",
|
||||
type: "session.instructions.updated",
|
||||
data: { sessionID: "ses_1", delta: { agents: "hash" }, text: "Changed instructions" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result?.messages).toEqual([
|
||||
{
|
||||
id: "msg_instructions",
|
||||
type: "system",
|
||||
text: "Changed instructions",
|
||||
description: "Instructions updated: agents",
|
||||
metadata: undefined,
|
||||
time: { created: 1 },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("projects session movement with the previous location", () => {
|
||||
const result = createV2SessionReducer().reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_moved",
|
||||
type: "session.moved",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
projectID: "project_2",
|
||||
location: { directory: "/repo-2" },
|
||||
subpath: "packages/app",
|
||||
},
|
||||
}),
|
||||
{ projectID: "project_1", location: { directory: "/repo-1" } },
|
||||
)
|
||||
|
||||
expect(result?.messages).toMatchObject([
|
||||
{
|
||||
id: "msg_moved",
|
||||
type: "location-switched",
|
||||
projectID: "project_2",
|
||||
location: { directory: "/repo-2" },
|
||||
previous: { projectID: "project_1", location: { directory: "/repo-1" } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("removes cancelled input from the pending promotion fold", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.inbox.enqueued",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inboxID: "msg_user",
|
||||
item: { type: "user", delivery: "queue", payload: { text: "cancel me" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_cancelled",
|
||||
type: "session.inbox.cancelled",
|
||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
const result = reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ missing: "msg_user" })
|
||||
})
|
||||
|
||||
test("keeps steered input available to the promotion fold", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.inbox.enqueued",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inboxID: "msg_user",
|
||||
item: { type: "user", delivery: "queue", payload: { text: "steer me" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_steered",
|
||||
type: "session.inbox.delivery.changed",
|
||||
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "steer" },
|
||||
}),
|
||||
)
|
||||
reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_queued",
|
||||
type: "session.inbox.delivery.changed",
|
||||
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "queue" },
|
||||
}),
|
||||
)
|
||||
|
||||
const result = reducer.reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_1", inboxID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
|
||||
})
|
||||
})
|
||||
@@ -1,592 +0,0 @@
|
||||
import type {
|
||||
OpenCodeEvent,
|
||||
SessionInboxInfo,
|
||||
SessionInboxItem,
|
||||
SessionInfo,
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
|
||||
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
|
||||
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
|
||||
type Shell = Extract<SessionMessageInfo, { type: "shell" }>
|
||||
|
||||
export type V2SessionReduction = {
|
||||
sessionID: string
|
||||
messages: SessionMessageInfo[]
|
||||
touched: string[]
|
||||
removed?: string[]
|
||||
missing?: string
|
||||
}
|
||||
|
||||
export function createV2SessionReducer() {
|
||||
const pending = new Map<string, SessionInboxItem>()
|
||||
|
||||
const reduce = (
|
||||
source: readonly SessionMessageInfo[],
|
||||
event: OpenCodeEvent,
|
||||
session?: Pick<SessionInfo, "location" | "projectID" | "subpath">,
|
||||
): V2SessionReduction | undefined => {
|
||||
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
||||
const sessionID = event.data.sessionID
|
||||
const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({
|
||||
sessionID,
|
||||
messages,
|
||||
touched,
|
||||
})
|
||||
const append = (message: SessionMessageInfo) =>
|
||||
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
|
||||
const replace = (message: SessionMessageInfo) =>
|
||||
result([...source.filter((item) => item.id !== message.id), message], [message.id])
|
||||
|
||||
switch (event.type) {
|
||||
case "session.inbox.enqueued":
|
||||
pending.set(key(sessionID, event.data.inboxID), event.data.item)
|
||||
if (event.data.item.type === "user")
|
||||
return replace({
|
||||
id: event.data.inboxID,
|
||||
type: "user",
|
||||
metadata: event.data.item.payload.metadata,
|
||||
text: event.data.item.payload.text,
|
||||
files: event.data.item.payload.files,
|
||||
agents: event.data.item.payload.agents,
|
||||
time: { created: event.created },
|
||||
})
|
||||
if (event.data.item.type !== "synthetic") return result([...source])
|
||||
return replace({
|
||||
id: event.data.inboxID,
|
||||
type: "synthetic",
|
||||
metadata: event.data.item.payload.metadata,
|
||||
text: event.data.item.payload.text,
|
||||
description: event.data.item.payload.description,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.inbox.cancelled":
|
||||
pending.delete(key(sessionID, event.data.inboxID))
|
||||
return {
|
||||
...result(source.filter((item) => item.id !== event.data.inboxID)),
|
||||
removed: source.some((item) => item.id === event.data.inboxID) ? [event.data.inboxID] : [],
|
||||
}
|
||||
case "session.inbox.delivered": {
|
||||
const input = pending.get(key(sessionID, event.data.inboxID))
|
||||
pending.delete(key(sessionID, event.data.inboxID))
|
||||
const existing = source.find((item) => item.id === event.data.inboxID)
|
||||
if (existing) {
|
||||
const promoted = { ...existing, time: { ...existing.time, created: event.created } }
|
||||
return result([...source.filter((item) => item.id !== existing.id), promoted], [existing.id])
|
||||
}
|
||||
if (!input) return { ...result([...source]), missing: event.data.inboxID }
|
||||
if (input.type === "user")
|
||||
return append({
|
||||
id: event.data.inboxID,
|
||||
type: "user",
|
||||
metadata: input.payload.metadata,
|
||||
text: input.payload.text,
|
||||
files: input.payload.files,
|
||||
agents: input.payload.agents,
|
||||
time: { created: event.created },
|
||||
})
|
||||
if (input.type !== "synthetic") return result([...source])
|
||||
return append({
|
||||
id: event.data.inboxID,
|
||||
type: "synthetic",
|
||||
metadata: input.payload.metadata,
|
||||
text: input.payload.text,
|
||||
description: input.payload.description,
|
||||
time: { created: event.created },
|
||||
})
|
||||
}
|
||||
case "session.agent.selected":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
previous:
|
||||
event.data.previous ??
|
||||
source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "agent-switched" | "assistant" }> =>
|
||||
item.type === "agent-switched" || item.type === "assistant",
|
||||
)?.agent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.model.selected":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
previous:
|
||||
event.data.previous ??
|
||||
source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||
item.type === "model-switched" || item.type === "assistant",
|
||||
)?.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.moved":
|
||||
if (!session) return
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "location-switched",
|
||||
metadata: event.metadata,
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
previous: {
|
||||
location: session.location,
|
||||
projectID: session.projectID,
|
||||
subpath: session.subpath,
|
||||
},
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.instructions.updated":
|
||||
if (event.data.text === undefined) return
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "system",
|
||||
text: event.data.text,
|
||||
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.synthetic":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "synthetic",
|
||||
metadata: event.data.metadata,
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.skill.activated":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "skill",
|
||||
metadata: event.metadata,
|
||||
skill: event.data.id,
|
||||
name: event.data.name,
|
||||
text: event.data.text,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.started":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "shell",
|
||||
metadata: event.metadata,
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.ended":
|
||||
return updateMessage<Shell>(
|
||||
source,
|
||||
(item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id,
|
||||
(item) => ({
|
||||
...item,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
output: event.data.output,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}),
|
||||
sessionID,
|
||||
)
|
||||
case "session.step.started": {
|
||||
const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
|
||||
const completed =
|
||||
current && current.id !== event.data.assistantMessageID
|
||||
? update(source, current.id, (item) =>
|
||||
item.type === "assistant"
|
||||
? { ...item, retry: undefined, time: { ...item.time, completed: event.created } }
|
||||
: item,
|
||||
)
|
||||
: [...source]
|
||||
const existing = completed.find((item) => item.id === event.data.assistantMessageID)
|
||||
if (existing?.type === "assistant")
|
||||
return result(
|
||||
update(completed, existing.id, (item) =>
|
||||
item.type === "assistant"
|
||||
? {
|
||||
...item,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
retry: undefined,
|
||||
error: undefined,
|
||||
finish: undefined,
|
||||
snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot,
|
||||
time: { ...item.time, completed: undefined },
|
||||
}
|
||||
: item,
|
||||
),
|
||||
current && current.id !== existing.id ? [current.id, existing.id] : [existing.id],
|
||||
)
|
||||
return result(
|
||||
[
|
||||
...completed,
|
||||
{
|
||||
id: event.data.assistantMessageID,
|
||||
type: "assistant",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
content: [],
|
||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||
time: { created: event.created },
|
||||
},
|
||||
],
|
||||
current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID],
|
||||
)
|
||||
}
|
||||
case "session.step.ended":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
finish: event.data.finish,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: item.snapshot,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}))
|
||||
case "session.step.failed":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
finish: "error",
|
||||
error: event.data.error,
|
||||
retry: undefined,
|
||||
cost: event.data.cost ?? item.cost,
|
||||
tokens: event.data.tokens ?? item.tokens,
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: item.snapshot,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}))
|
||||
case "session.text.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: insertOrdinal(item.content, "text", event.data.ordinal, { type: "text", text: "" }),
|
||||
}))
|
||||
case "session.text.delta":
|
||||
return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({
|
||||
...item,
|
||||
text: item.text + event.data.delta,
|
||||
}))
|
||||
case "session.text.ended":
|
||||
return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({
|
||||
...item,
|
||||
text: event.data.text,
|
||||
}))
|
||||
case "session.reasoning.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: insertOrdinal(item.content, "reasoning", event.data.ordinal, {
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: event.data.state,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
}))
|
||||
case "session.reasoning.delta":
|
||||
return updateContent(
|
||||
source,
|
||||
event.data.assistantMessageID,
|
||||
sessionID,
|
||||
"reasoning",
|
||||
event.data.ordinal,
|
||||
(item) => ({
|
||||
...item,
|
||||
text: item.text + event.data.delta,
|
||||
}),
|
||||
)
|
||||
case "session.reasoning.ended":
|
||||
return updateContent(
|
||||
source,
|
||||
event.data.assistantMessageID,
|
||||
sessionID,
|
||||
"reasoning",
|
||||
event.data.ordinal,
|
||||
(item) => ({
|
||||
...item,
|
||||
text: event.data.text,
|
||||
state: event.data.state ?? item.state,
|
||||
time: { created: item.time?.created ?? event.created, completed: event.created },
|
||||
}),
|
||||
)
|
||||
case "session.tool.input.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: item.content.some((content) => content.type === "tool" && content.id === event.data.id)
|
||||
? item.content
|
||||
: [
|
||||
...item.content,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
name: event.data.name,
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: event.created },
|
||||
},
|
||||
],
|
||||
}))
|
||||
case "session.tool.input.delta":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
|
||||
tool.state.status === "streaming"
|
||||
? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } }
|
||||
: tool,
|
||||
)
|
||||
case "session.tool.input.ended":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
|
||||
tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool,
|
||||
)
|
||||
case "session.tool.called":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => ({
|
||||
...tool,
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
// structured: {}, content: []
|
||||
state: { status: "running", input: event.data.input, metadata: {} },
|
||||
time: { ...tool.time, ran: event.created },
|
||||
}))
|
||||
case "session.tool.progress":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
|
||||
tool.state.status === "running"
|
||||
? {
|
||||
...tool,
|
||||
// state: { ...tool.state, structured: event.data.structured, content: event.data.content },
|
||||
state: { ...tool.state, metadata: event.data.metadata },
|
||||
}
|
||||
: tool,
|
||||
)
|
||||
case "session.tool.success":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => {
|
||||
if (tool.state.status !== "running") return tool
|
||||
return {
|
||||
...tool,
|
||||
executed: event.data.executed || tool.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: tool.state.input,
|
||||
// structured: event.data.structured,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
// result: event.data.result,
|
||||
},
|
||||
time: { ...tool.time, completed: event.created },
|
||||
}
|
||||
})
|
||||
case "session.tool.failed":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => {
|
||||
if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool
|
||||
return {
|
||||
...tool,
|
||||
executed: event.data.executed || tool.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
state: {
|
||||
status: "error",
|
||||
input: typeof tool.state.input === "string" ? {} : tool.state.input,
|
||||
// structured: tool.state.status === "running" ? tool.state.structured : {},
|
||||
metadata: event.data.metadata ?? (tool.state.status === "running" ? tool.state.metadata : {}),
|
||||
content: event.data.content,
|
||||
error: event.data.error,
|
||||
// result: event.data.result,
|
||||
},
|
||||
time: { ...tool.time, completed: event.created },
|
||||
}
|
||||
})
|
||||
case "session.retry.scheduled":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error },
|
||||
}))
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.failed":
|
||||
case "session.execution.interrupted": {
|
||||
const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
|
||||
if (!current?.retry) return result([...source])
|
||||
return updateAssistant(source, current.id, sessionID, (item) => ({ ...item, retry: undefined }))
|
||||
}
|
||||
case "session.compaction.started":
|
||||
return append({
|
||||
id: event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent ?? "",
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.compaction.delta":
|
||||
return updateMessage<Extract<Compaction, { status: "running" }>>(
|
||||
source,
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
(item) => ({
|
||||
...item,
|
||||
summary: item.summary + event.data.text,
|
||||
}),
|
||||
sessionID,
|
||||
)
|
||||
case "session.compaction.ended": {
|
||||
const current = source.findLast(
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
)
|
||||
if (!current)
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return result(
|
||||
update(source, current.id, () => ({
|
||||
...current,
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
})),
|
||||
[current.id],
|
||||
)
|
||||
}
|
||||
case "session.compaction.failed": {
|
||||
const current = source.findLast(
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
)
|
||||
const failed: Extract<Compaction, { status: "failed" }> = {
|
||||
id: current?.id ?? event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
metadata: current?.metadata ?? event.metadata,
|
||||
reason: event.data.reason ?? "manual",
|
||||
error: event.data.error ?? {
|
||||
type: "compaction.failed",
|
||||
message: "Compaction failed before recording an error",
|
||||
},
|
||||
time: current?.time ?? { created: event.created },
|
||||
}
|
||||
if (!current) return append(failed)
|
||||
return result(
|
||||
update(source, current.id, () => failed),
|
||||
[failed.id],
|
||||
)
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reduce,
|
||||
confirm(item: SessionInboxInfo) {
|
||||
pending.set(key(item.sessionID, item.id), item)
|
||||
},
|
||||
clear(sessionID: string) {
|
||||
for (const id of pending.keys()) {
|
||||
if (id.startsWith(`${sessionID}:`)) pending.delete(id)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function key(sessionID: string, inputID: string) {
|
||||
return `${sessionID}:${inputID}`
|
||||
}
|
||||
|
||||
function messageID(eventID: string) {
|
||||
return eventID.replace(/^evt_/, "msg_")
|
||||
}
|
||||
|
||||
function update(
|
||||
source: readonly SessionMessageInfo[],
|
||||
id: string,
|
||||
apply: (item: SessionMessageInfo) => SessionMessageInfo,
|
||||
) {
|
||||
return source.map((item) => (item.id === id ? apply(item) : item))
|
||||
}
|
||||
|
||||
function updateMessage<T extends SessionMessageInfo>(
|
||||
source: readonly SessionMessageInfo[],
|
||||
matches: (item: SessionMessageInfo) => item is T,
|
||||
apply: (item: T) => T,
|
||||
sessionID: string,
|
||||
): V2SessionReduction {
|
||||
const current = source.findLast(matches)
|
||||
if (!current) return { sessionID, messages: [...source], touched: [] }
|
||||
return {
|
||||
sessionID,
|
||||
messages: update(source, current.id, (item) => (matches(item) ? apply(item) : item)),
|
||||
touched: [current.id],
|
||||
}
|
||||
}
|
||||
|
||||
function updateAssistant(
|
||||
source: readonly SessionMessageInfo[],
|
||||
id: string,
|
||||
sessionID: string,
|
||||
apply: (item: Assistant) => Assistant,
|
||||
): V2SessionReduction {
|
||||
return {
|
||||
sessionID,
|
||||
messages: update(source, id, (item) => (item.type === "assistant" ? apply(item) : item)),
|
||||
touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function updateContent<T extends "text" | "reasoning">(
|
||||
source: readonly SessionMessageInfo[],
|
||||
messageID: string,
|
||||
sessionID: string,
|
||||
type: T,
|
||||
ordinal: number,
|
||||
apply: (
|
||||
item: Extract<Assistant["content"][number], { type: T }>,
|
||||
) => Extract<Assistant["content"][number], { type: T }>,
|
||||
) {
|
||||
return updateAssistant(source, messageID, sessionID, (assistant) => {
|
||||
let index = -1
|
||||
return {
|
||||
...assistant,
|
||||
content: assistant.content.map((item) => {
|
||||
if (item.type !== type || ++index !== ordinal) return item
|
||||
return apply(item as Extract<Assistant["content"][number], { type: T }>)
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateTool(
|
||||
source: readonly SessionMessageInfo[],
|
||||
messageID: string,
|
||||
callID: string,
|
||||
sessionID: string,
|
||||
apply: (
|
||||
item: Extract<Assistant["content"][number], { type: "tool" }>,
|
||||
) => Extract<Assistant["content"][number], { type: "tool" }>,
|
||||
) {
|
||||
return updateAssistant(source, messageID, sessionID, (assistant) => ({
|
||||
...assistant,
|
||||
content: assistant.content.map((item) => (item.type === "tool" && item.id === callID ? apply(item) : item)),
|
||||
}))
|
||||
}
|
||||
|
||||
function insertOrdinal<T extends Assistant["content"][number]["type"]>(
|
||||
source: Assistant["content"],
|
||||
type: T,
|
||||
ordinal: number,
|
||||
item: Extract<Assistant["content"][number], { type: T }>,
|
||||
) {
|
||||
const matches = source.filter((content) => content.type === type)
|
||||
if (matches[ordinal]) return source
|
||||
return [...source, item]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,126 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
McpListInput,
|
||||
McpResourceCatalogInput,
|
||||
OpenCodeEvent,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
SessionListInput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||
import {
|
||||
loadActiveSessionsQuery,
|
||||
loadMcpQuery,
|
||||
loadMcpResourcesQuery,
|
||||
reconcileActiveSessionStatuses,
|
||||
seedActiveSessionStatuses,
|
||||
sessionListEventDirectories,
|
||||
shouldRefreshWorkspaceSessions,
|
||||
} from "./server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerSession } from "./server-session"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import { adaptServerEvent } from "./server-sdk"
|
||||
|
||||
type McpApi = ServerApi["mcp"]
|
||||
|
||||
describe("MCP queries", () => {
|
||||
test("loads current servers for the requested location", async () => {
|
||||
const calls: unknown[] = []
|
||||
const queryClient = new QueryClient()
|
||||
const result = await queryClient.fetchQuery(
|
||||
loadMcpQuery(ServerScope.local, "/project", {
|
||||
list: async (input: McpListInput = {}) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
||||
data: [
|
||||
{ name: "docs", status: { status: "connected" } },
|
||||
{ name: "search", status: { status: "pending" } },
|
||||
],
|
||||
}
|
||||
},
|
||||
} as unknown as McpApi),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
||||
expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } })
|
||||
})
|
||||
|
||||
test("loads and keys the current resource catalog", async () => {
|
||||
const calls: unknown[] = []
|
||||
const queryClient = new QueryClient()
|
||||
const result = await queryClient.fetchQuery(
|
||||
loadMcpResourcesQuery(ServerScope.local, "/project", {
|
||||
resource: {
|
||||
catalog: async (input: McpResourceCatalogInput = {}) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
||||
data: {
|
||||
resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }],
|
||||
templates: [],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
} as unknown as McpApi),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
||||
expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("active session query", () => {
|
||||
test("loads active sessions immediately and once per server cache", async () => {
|
||||
let calls = 0
|
||||
const queryClient = new QueryClient()
|
||||
const options = loadActiveSessionsQuery(ServerScope.local, {
|
||||
active: async () => {
|
||||
calls++
|
||||
return { ses_running: { type: "running" } }
|
||||
},
|
||||
})
|
||||
|
||||
expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
|
||||
expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
|
||||
expect(calls).toBe(1)
|
||||
expect(options.enabled).toBe(true)
|
||||
expect([...options.queryKey]).toEqual([ServerScope.local, "activeSessions"])
|
||||
})
|
||||
|
||||
test("does not overwrite statuses already written by events", () => {
|
||||
const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"])
|
||||
session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 })
|
||||
|
||||
seedActiveSessionStatuses(session, {
|
||||
ses_running: { type: "running" },
|
||||
ses_retry: { type: "running" },
|
||||
})
|
||||
|
||||
expect(session.data.session_status.ses_running).toEqual({ type: "busy" })
|
||||
expect(session.data.session_status.ses_retry).toEqual({
|
||||
type: "retry",
|
||||
attempt: 2,
|
||||
message: "retrying",
|
||||
next: 10,
|
||||
})
|
||||
})
|
||||
|
||||
test("replaces stale active statuses after reconnect", () => {
|
||||
const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"])
|
||||
session.set("session_status", "ses_stale", { type: "busy" })
|
||||
session.set("session_status", "ses_active", { type: "idle" })
|
||||
|
||||
reconcileActiveSessionStatuses(session, { ses_active: { type: "running" } })
|
||||
|
||||
expect(session.data.session_status.ses_stale).toEqual({ type: "idle" })
|
||||
expect(session.data.session_status.ses_active).toEqual({ type: "busy" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
@@ -143,99 +22,6 @@ describe("pickDirectoriesToEvict", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadRootSessions", () => {
|
||||
test("loads a limited page of root sessions", async () => {
|
||||
const calls: SessionListInput[] = []
|
||||
|
||||
const result = await loadRootSessions({
|
||||
api: {
|
||||
list: async (query = {}) => {
|
||||
calls.push(query)
|
||||
return { data: [sessionInfo("session-1")], cursor: {} }
|
||||
},
|
||||
} satisfies Pick<SessionApi, "list">,
|
||||
directory: "dir",
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
expect(result.data).toEqual([expect.objectContaining({ id: "session-1", location: { directory: "dir" } })])
|
||||
expect(result.limited).toBe(true)
|
||||
expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }])
|
||||
})
|
||||
|
||||
test("propagates list failures", () => {
|
||||
expect(
|
||||
loadRootSessions({
|
||||
api: {
|
||||
list: async () => {
|
||||
throw new Error("failed")
|
||||
},
|
||||
} satisfies Pick<SessionApi, "list">,
|
||||
directory: "dir",
|
||||
limit: 25,
|
||||
}),
|
||||
).rejects.toThrow("failed")
|
||||
})
|
||||
})
|
||||
|
||||
function sessionInfo(id: string) {
|
||||
return {
|
||||
id,
|
||||
projectID: "project-1",
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
title: id,
|
||||
location: { directory: "dir" },
|
||||
} as SessionInfo
|
||||
}
|
||||
|
||||
describe("estimateRootSessionTotal", () => {
|
||||
test("keeps exact total for full fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||
})
|
||||
|
||||
test("marks has-more for full-limit limited fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 10, limit: 10, limited: true })).toBe(11)
|
||||
})
|
||||
|
||||
test("keeps exact total when limited fetch is under limit", () => {
|
||||
expect(estimateRootSessionTotal({ count: 9, limit: 10, limited: true })).toBe(9)
|
||||
})
|
||||
})
|
||||
|
||||
describe("workspace session inventory", () => {
|
||||
test("refreshes for session identity and location changes", () => {
|
||||
const event = (type: string, current?: string) =>
|
||||
({ type, current: current ? { type: current } : undefined }) as Parameters<
|
||||
typeof shouldRefreshWorkspaceSessions
|
||||
>[0]
|
||||
|
||||
expect(shouldRefreshWorkspaceSessions(event("session.created"))).toBe(true)
|
||||
expect(shouldRefreshWorkspaceSessions(event("session.updated", "session.moved"))).toBe(true)
|
||||
expect(shouldRefreshWorkspaceSessions(event("message.updated"))).toBe(false)
|
||||
})
|
||||
|
||||
test("invalidates both locations when a session moves", () => {
|
||||
const event = adaptServerEvent({
|
||||
id: "evt_moved",
|
||||
created: 1,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||
location: { directory: "/source" },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
location: { directory: "/destination" },
|
||||
projectID: "project_2",
|
||||
},
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>)
|
||||
|
||||
expect(sessionListEventDirectories(event)).toEqual(["/source", "/destination"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("canDisposeDirectory", () => {
|
||||
test("rejects pinned or inflight directories", () => {
|
||||
expect(
|
||||
|
||||
@@ -1,159 +1,40 @@
|
||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { batch, getOwner, onCleanup, untrack } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { getOwner, onCleanup, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { type ServerEvent, type ServerSDK } from "./server-sdk"
|
||||
import { type ServerSDK } from "./server-sdk"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
bootstrapGlobal,
|
||||
clearProviderRev,
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadGlobalConfigQuery,
|
||||
loadIntegrationsQuery,
|
||||
loadPathQuery,
|
||||
loadProjectsQuery,
|
||||
loadProvidersQuery,
|
||||
loadReferencesQuery,
|
||||
} from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import type { SolidQueryOptions } from "@tanstack/solid-query"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { directoryKey } from "./global-sync/utils"
|
||||
import { pathKey, PathKey } from "@/utils/path-key"
|
||||
import { createDirSyncContext } from "./directory-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
import { ServerConnection } from "./servers"
|
||||
import { PathKey } from "@/utils/path-key"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import type {
|
||||
McpListInput,
|
||||
McpListOutput,
|
||||
McpResource,
|
||||
McpResourceCatalogInput,
|
||||
McpResourceCatalogOutput,
|
||||
McpServer,
|
||||
SessionActiveOutput,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession, type ServerSession } from "./server-session"
|
||||
import { createCatalogSync } from "./server-sync/catalog"
|
||||
import { createConnectionSync } from "./server-sync/connection"
|
||||
import { usePlatform } from "./platform"
|
||||
import { useServer } from "./server"
|
||||
|
||||
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||
const type = event.current?.type ?? event.type
|
||||
return (
|
||||
type === "session.created" ||
|
||||
type === "session.deleted" ||
|
||||
type === "session.moved" ||
|
||||
type === "session.renamed" ||
|
||||
type === "session.forked"
|
||||
)
|
||||
}
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
error?: InitError
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: NormalizedProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
const SESSION_LIST_EVENTS = new Set([
|
||||
"session.created",
|
||||
"session.updated",
|
||||
"session.deleted",
|
||||
"session.moved",
|
||||
"session.forked",
|
||||
"session.renamed",
|
||||
"session.usage.updated",
|
||||
])
|
||||
|
||||
export function sessionListEventDirectories(event: ServerEvent) {
|
||||
if (!SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) return []
|
||||
const destination = event.current?.type === "session.moved" ? event.current.data.location.directory : undefined
|
||||
return [...new Set([event.current?.location?.directory, destination].filter((item): item is string => !!item))]
|
||||
}
|
||||
|
||||
type McpListApi = {
|
||||
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
||||
}
|
||||
|
||||
type McpResourceApi = {
|
||||
readonly resource: {
|
||||
readonly catalog: (input?: McpResourceCatalogInput) => Promise<McpResourceCatalogOutput>
|
||||
}
|
||||
}
|
||||
|
||||
type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
|
||||
initialData?: undefined
|
||||
queryKey: K
|
||||
}
|
||||
|
||||
type SessionActiveApi = {
|
||||
readonly active: () => Promise<SessionActiveOutput>
|
||||
}
|
||||
|
||||
export const loadMcpQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: McpListApi,
|
||||
): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
|
||||
queryOptions<
|
||||
Record<string, McpServer["status"]>,
|
||||
Error,
|
||||
Record<string, McpServer["status"]>,
|
||||
readonly [ServerScope, string, "mcp"]
|
||||
>({
|
||||
queryKey: [scope, directory, "mcp"] as const,
|
||||
queryFn: async () => {
|
||||
return api
|
||||
.list({ location: { directory } })
|
||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
||||
},
|
||||
})
|
||||
|
||||
export const loadMcpResourcesQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: McpResourceApi,
|
||||
): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
|
||||
queryOptions<
|
||||
Record<string, McpResource>,
|
||||
Error,
|
||||
Record<string, McpResource>,
|
||||
readonly [ServerScope, string, "mcpResources"]
|
||||
>({
|
||||
queryKey: [scope, directory, "mcpResources"] as const,
|
||||
queryFn: async () => {
|
||||
return api.resource
|
||||
.catalog({ location: { directory } })
|
||||
.then((result) =>
|
||||
Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
|
||||
)
|
||||
},
|
||||
placeholderData: {},
|
||||
})
|
||||
|
||||
export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "lsp"] as const,
|
||||
@@ -161,112 +42,32 @@ export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
||||
queryFn: async () => [],
|
||||
})
|
||||
|
||||
export const loadActiveSessionsQuery = (
|
||||
scope: ServerScope,
|
||||
api: SessionActiveApi,
|
||||
): ApiQueryOptions<SessionActiveOutput, readonly [ServerScope, "activeSessions"]> =>
|
||||
queryOptions<SessionActiveOutput, Error, SessionActiveOutput, readonly [ServerScope, "activeSessions"]>({
|
||||
queryKey: [scope, "activeSessions"] as const,
|
||||
queryFn: () => api.active(),
|
||||
enabled: true,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
export function seedActiveSessionStatuses(
|
||||
session: Pick<ServerSession, "data" | "set">,
|
||||
active: SessionActiveOutput | Record<string, SessionStatus>,
|
||||
) {
|
||||
for (const sessionID of Object.keys(active)) {
|
||||
if (session.data.session_status[sessionID] !== undefined) continue
|
||||
const status = active[sessionID]
|
||||
session.set("session_status", sessionID, status?.type === "running" ? { type: "busy" } : status)
|
||||
}
|
||||
}
|
||||
|
||||
export function reconcileActiveSessionStatuses(
|
||||
session: Pick<ServerSession, "data" | "set">,
|
||||
active: SessionActiveOutput,
|
||||
) {
|
||||
Object.keys(session.data.session_status)
|
||||
.filter((sessionID) => active[sessionID] === undefined && session.data.session_status[sessionID]?.type !== "idle")
|
||||
.forEach((sessionID) => session.set("session_status", sessionID, { type: "idle" }))
|
||||
Object.keys(active).forEach((sessionID) => session.set("session_status", sessionID, { type: "busy" }))
|
||||
}
|
||||
|
||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||
projects: () => loadProjectsQuery(scope, serverAPI.project, serverAPI.worktree),
|
||||
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
||||
integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration),
|
||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
|
||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
|
||||
path: () => loadPathQuery(scope, null, serverAPI.location),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
|
||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||
}
|
||||
}
|
||||
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
||||
|
||||
export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const owner = getOwner()
|
||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||
|
||||
const booting = new Map<string, Promise<void>>()
|
||||
const sessionLoads = new Map<string, Promise<void>>()
|
||||
const sessionMeta = new Map<string, { limit: number }>()
|
||||
const sessionRevision = new Map<string, number>()
|
||||
|
||||
const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
|
||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
|
||||
const connected = () => serverSDK.connection.status() === "connected"
|
||||
const hydrateSessionState = async (sessionID: string) => {
|
||||
await session.hydrateTransient(sessionID, async () => {
|
||||
const [pending, forms] = await Promise.all([
|
||||
serverSDK.api.session.inbox.list({ sessionID }),
|
||||
serverSDK.api.form.list({ sessionID }),
|
||||
])
|
||||
return { pending, forms }
|
||||
})
|
||||
}
|
||||
const hydrateSession = async (sessionID: string) => {
|
||||
await Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
|
||||
session.inbox.reconcile(sessionID)
|
||||
}
|
||||
|
||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||
const [configQuery, pathQuery] = useQueries(() => ({
|
||||
queries: [
|
||||
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
|
||||
{ ...queryOptionsApi.providers(null), enabled: connected() },
|
||||
{ ...queryOptionsApi.path(null), enabled: connected() },
|
||||
{ ...queryOptionsApi.path(), enabled: connected() },
|
||||
],
|
||||
}))
|
||||
const activeSessionsQuery = useQuery(() => ({
|
||||
...loadActiveSessionsQuery(serverSDK.scope, {
|
||||
active: async () => {
|
||||
const active = await serverSDK.api.session.active()
|
||||
reconcileActiveSessionStatuses(session, active)
|
||||
Object.keys(active).forEach((sessionID) => {
|
||||
void Promise.all([session.resolve(sessionID), hydrateSessionState(sessionID)]).catch(() => undefined)
|
||||
})
|
||||
return active
|
||||
},
|
||||
}),
|
||||
enabled: connected(),
|
||||
}))
|
||||
|
||||
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||
get ready() {
|
||||
return bootstrap.isSuccess
|
||||
},
|
||||
project: [],
|
||||
provider_auth: {},
|
||||
get path() {
|
||||
@@ -274,11 +75,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
if (pathQuery.isLoading) return EMPTY
|
||||
return pathQuery.data ?? EMPTY
|
||||
},
|
||||
get provider() {
|
||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||
if (providerQuery.isLoading) return EMPTY
|
||||
return providerQuery.data ?? EMPTY
|
||||
},
|
||||
get config() {
|
||||
if (configQuery.isLoading) return {}
|
||||
return configQuery.data ?? {}
|
||||
@@ -289,7 +85,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
|
||||
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
||||
setGlobalStore("project", next)
|
||||
}
|
||||
@@ -342,162 +137,56 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
scope: serverSDK.scope,
|
||||
persist: persisted,
|
||||
isBooting: (directory) => booting.has(directory),
|
||||
isLoadingSessions: (directory) => sessionLoads.has(directory),
|
||||
isLoadingSessions: () => false,
|
||||
onBootstrap: (directory) => {
|
||||
if (!connected()) return
|
||||
void bootstrapInstance(directory)
|
||||
},
|
||||
onMcp: (directory, setStore) => {
|
||||
void loadCommands(directory, serverSDK.api.command)
|
||||
.then((commands) => setStore("command", commands))
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
onMcp: (directory) => {
|
||||
void Promise.all([
|
||||
data.location.command.sync({ directory }),
|
||||
data.location.mcp.server.sync({ directory }),
|
||||
data.location.mcp.resource.sync({ directory }),
|
||||
]).catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
})
|
||||
},
|
||||
onDispose: (directory) => {
|
||||
const key = directoryKey(directory)
|
||||
queue.clear(key)
|
||||
sessionMeta.delete(key)
|
||||
clearProviderRev(serverSDK.scope, key)
|
||||
},
|
||||
translate: language.t,
|
||||
queryOptions: queryOptionsApi,
|
||||
data,
|
||||
global: {
|
||||
get provider() {
|
||||
return globalStore.provider
|
||||
get path() {
|
||||
return globalStore.path
|
||||
},
|
||||
},
|
||||
})
|
||||
const catalog = createCatalogSync({
|
||||
scope: serverSDK.scope,
|
||||
queryClient,
|
||||
active: () => Object.keys(children.children).filter(children.active).map(pathKey),
|
||||
load: (directory) =>
|
||||
Promise.all([
|
||||
queryClient.fetchQuery(queryOptionsApi.providers(directory)),
|
||||
queryClient.fetchQuery(queryOptionsApi.integrations(directory)),
|
||||
]).then(() => undefined),
|
||||
})
|
||||
const refreshVcs = (directory: string) =>
|
||||
serverSDK.api.vcs
|
||||
.get({ location: { directory } })
|
||||
.then((result) =>
|
||||
children.vcs(directory, {
|
||||
branch: result.data.branch.current,
|
||||
default_branch: result.data.branch.default,
|
||||
}),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
const connection = createConnectionSync({
|
||||
status: serverSDK.connection.status,
|
||||
invalidate: () => {
|
||||
session.invalidate()
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey[0] === serverSDK.scope,
|
||||
refetchType: "none",
|
||||
})
|
||||
},
|
||||
connected: (info) => {
|
||||
if (info.reconnect) void session.refreshPinned(hydrateSessionState).catch(() => undefined)
|
||||
if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch()
|
||||
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
|
||||
Object.keys(children.children)
|
||||
.filter(children.active)
|
||||
.forEach((directory) => {
|
||||
queue.push(directory)
|
||||
if (children.children[directory]?.[0].status !== "loading") void refreshVcs(directory)
|
||||
void data.location.sync({ directory }).catch(() => undefined)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
async function loadCurrentSessions(directory: string, key: PathKey, limit: number) {
|
||||
while (true) {
|
||||
const revision = sessionRevision.get(key) ?? 0
|
||||
const result = await loadRootSessions({ api: serverSDK.api.session, directory, limit })
|
||||
if ((sessionRevision.get(key) ?? 0) === revision) return result
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessions(directory: string, options?: { limit?: number }) {
|
||||
const key = directoryKey(directory)
|
||||
const pending = sessionLoads.get(key)
|
||||
if (pending) {
|
||||
await pending
|
||||
return loadSessions(directory, options)
|
||||
}
|
||||
|
||||
children.pin(key)
|
||||
const [store, setStore] = children.child(directory, { bootstrap: false })
|
||||
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, {
|
||||
limit: retainedLimit,
|
||||
permission: session.data.permission,
|
||||
})
|
||||
if (next.length !== store.session.length) {
|
||||
setStore("session", reconcile(next, { key: "id" }))
|
||||
}
|
||||
children.unpin(key)
|
||||
return
|
||||
}
|
||||
|
||||
const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
|
||||
const promise = queryClient
|
||||
.fetchQuery({
|
||||
...queryOptionsApi.sessions(key),
|
||||
queryFn: () =>
|
||||
loadCurrentSessions(directory, key, limit)
|
||||
.then((x) => {
|
||||
const nonArchived = (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 next = trimSessions([...nonArchived, ...childSessions], {
|
||||
limit,
|
||||
permission: session.data.permission,
|
||||
})
|
||||
batch(() => {
|
||||
next.forEach(session.remember)
|
||||
setStore(
|
||||
"sessionTotal",
|
||||
estimateRootSessionTotal({
|
||||
count: nonArchived.length,
|
||||
limit: x.limit,
|
||||
limited: x.limited,
|
||||
}),
|
||||
)
|
||||
setStore("session", reconcile(next, { key: "id" }))
|
||||
})
|
||||
sessionMeta.set(key, { limit })
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load sessions", err)
|
||||
const project = getFilename(directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.listFailed.title", { project }),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
})
|
||||
.then(() => null),
|
||||
})
|
||||
.then(() => {})
|
||||
|
||||
sessionLoads.set(key, promise)
|
||||
void promise.finally(() => {
|
||||
sessionLoads.delete(key)
|
||||
children.unpin(key)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
async function bootstrapInstance(directory: string) {
|
||||
const key = directoryKey(directory)
|
||||
if (!key) return
|
||||
@@ -507,8 +196,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
children.pin(key)
|
||||
const promise = Promise.resolve().then(async () => {
|
||||
const child = children.ensureChild(directory)
|
||||
const initial = child[0].status === "loading"
|
||||
await Promise.all([
|
||||
data.location.sync({ directory }),
|
||||
bootstrapDirectory({
|
||||
directory,
|
||||
scope: serverSDK.scope,
|
||||
@@ -517,17 +206,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
config: globalStore.config,
|
||||
path: globalStore.path,
|
||||
project: globalStore.project,
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
api: serverSDK.api,
|
||||
store: child[0],
|
||||
setStore: child[1],
|
||||
loadSessions,
|
||||
translate: language.t,
|
||||
queryClient,
|
||||
session,
|
||||
}),
|
||||
initial ? refreshVcs(directory) : Promise.resolve(),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -539,100 +224,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return promise
|
||||
}
|
||||
|
||||
const indexSession = (info: Parameters<typeof session.remember>[0]) => {
|
||||
const key = directoryKey(info.location.directory)
|
||||
const existing = children.children[key]
|
||||
if (!existing) return
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.created", properties: { info } },
|
||||
directory: key,
|
||||
store: existing[0],
|
||||
setStore: existing[1],
|
||||
push: queue.push,
|
||||
retainedLimit: sessionMeta.get(key)?.limit,
|
||||
sessionContent: false,
|
||||
permission: session.data.permission,
|
||||
loadLsp() {},
|
||||
})
|
||||
}
|
||||
const updateHomeSession = (info: Parameters<typeof session.remember>[0]) =>
|
||||
homeSessions.apply({
|
||||
type: "session.updated",
|
||||
properties: { sessionID: info.id, info },
|
||||
})
|
||||
const markSessionListsChanged = (event: ServerEvent) => {
|
||||
sessionListEventDirectories(event).forEach((directory) => {
|
||||
const key = directoryKey(directory)
|
||||
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
|
||||
})
|
||||
}
|
||||
const toDirectoryEvent = (event: ServerEvent) => {
|
||||
if (event.current?.type === "session.created") return
|
||||
if (event.current?.type !== "session.renamed" && event.current?.type !== "session.usage.updated") return event
|
||||
const info = session.get(event.current.data.sessionID)
|
||||
if (info) return { type: "session.updated", properties: { info } }
|
||||
return event
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const unsub = serverSDK.eventByDir.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
const directory = event.current?.location?.directory
|
||||
const eventType: string = event.type
|
||||
markSessionListsChanged(event)
|
||||
if (event.current) session.applyV2(event.current)
|
||||
session.apply(event)
|
||||
if (event.current?.type === "session.moved") {
|
||||
const info = session.get(event.current.data.sessionID)
|
||||
if (info) indexSession(info)
|
||||
}
|
||||
if (shouldRefreshWorkspaceSessions(event)) {
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions",
|
||||
})
|
||||
}
|
||||
if (event.current?.type === "session.created")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
.then((info) => {
|
||||
if (!session.get(info.id)) return
|
||||
indexSession(info)
|
||||
homeSessions.apply({
|
||||
type: "session.created",
|
||||
properties: { sessionID: info.id, info },
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
if (event.current?.type === "session.deleted")
|
||||
homeSessions.apply({
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: event.current.data.sessionID },
|
||||
})
|
||||
if (event.type === "session.created" || event.type === "session.deleted") {
|
||||
if ("info" in event.properties) homeSessions.apply(event as Parameters<typeof homeSessions.apply>[0])
|
||||
}
|
||||
if (
|
||||
event.current?.type === "session.renamed" ||
|
||||
event.current?.type === "session.moved" ||
|
||||
event.current?.type === "session.usage.updated"
|
||||
) {
|
||||
const sessionID = event.current.data.sessionID
|
||||
const info = session.get(sessionID)
|
||||
if (info) updateHomeSession(info)
|
||||
if (!info)
|
||||
void session
|
||||
.resolve(sessionID)
|
||||
.then(() => {
|
||||
const current = session.get(sessionID)
|
||||
if (current) updateHomeSession(current)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
homeSessions.refresh(event.type)
|
||||
catalog.handleEvent({ type: eventType, directory })
|
||||
connection.handleEvent({ type: eventType })
|
||||
|
||||
if (!directory) {
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
event,
|
||||
project: globalStore.project,
|
||||
@@ -645,50 +244,25 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = directoryKey(directory)
|
||||
if (event.current?.type === "session.forked")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
.then(indexSession)
|
||||
.catch(() => {})
|
||||
|
||||
const existing = children.children[key]
|
||||
if (!existing) return
|
||||
children.mark(key)
|
||||
if (
|
||||
event.current?.type === "session.moved" ||
|
||||
// event.current?.type === "session.archived" ||
|
||||
event.current?.type === "session.forked" ||
|
||||
eventType === "config.updated" ||
|
||||
eventType === "agent.updated"
|
||||
)
|
||||
queue.push(key)
|
||||
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
|
||||
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
|
||||
const [store, setStore] = existing
|
||||
if (eventType === "agent.updated")
|
||||
void queryClient
|
||||
.fetchQuery(queryOptionsApi.agents(key))
|
||||
.then((data) => setStore("agent", data))
|
||||
.catch(() => {})
|
||||
if (eventType === "command.updated")
|
||||
void loadCommands(directory, serverSDK.api.command)
|
||||
.then((commands) => setStore("command", commands))
|
||||
.catch(() => {})
|
||||
if (eventType === "worktree.updated") void bootstrap.refetch()
|
||||
const projected = toDirectoryEvent(event)
|
||||
if (projected)
|
||||
if (eventType !== "vcs.branch.updated")
|
||||
applyDirectoryEvent({
|
||||
event: projected,
|
||||
event,
|
||||
directory,
|
||||
store,
|
||||
setStore,
|
||||
push: (directory) => {
|
||||
if (children.active(directory)) queue.push(directory)
|
||||
},
|
||||
retainedLimit: sessionMeta.get(key)?.limit,
|
||||
sessionContent: false,
|
||||
permission: session.data.permission,
|
||||
vcsCache: children.vcsCache.get(key),
|
||||
loadLsp: () => {
|
||||
if (!children.active(key)) return
|
||||
@@ -696,7 +270,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
},
|
||||
loadReferences: () => {
|
||||
if (!children.active(key)) return
|
||||
void queryClient.fetchQuery(queryOptionsApi.references(key))
|
||||
void data.location.reference.sync({ directory: key }).catch(() => undefined)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -712,7 +286,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
})
|
||||
|
||||
const projectApi = {
|
||||
loadSessions,
|
||||
meta(directory: string, patch: ProjectMeta) {
|
||||
children.projectMeta(directory, patch)
|
||||
},
|
||||
@@ -729,34 +302,20 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
},
|
||||
onSuccess: () => {
|
||||
bootstrap.refetch()
|
||||
// Invalidate all provider queries so newly configured custom providers
|
||||
// appear immediately in the available provider list across all directories.
|
||||
queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] })
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
|
||||
})
|
||||
data.location.provider.invalidate()
|
||||
data.location.model.invalidate()
|
||||
void Promise.all([data.location.provider.sync(), data.location.model.sync()])
|
||||
},
|
||||
}))
|
||||
|
||||
return {
|
||||
data: globalStore,
|
||||
set,
|
||||
get ready() {
|
||||
return globalStore.ready
|
||||
},
|
||||
get error() {
|
||||
return globalStore.error
|
||||
},
|
||||
child: children.child,
|
||||
peek: children.peek,
|
||||
disableMcp: children.disableMcp,
|
||||
queryOptions: queryOptionsApi,
|
||||
refreshProviders: catalog.refreshActive,
|
||||
// bootstrap,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
project: projectApi,
|
||||
session: Object.assign(session, { hydrate: hydrateSession }),
|
||||
homeSessions,
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
@@ -790,8 +349,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
platform.openExternal(attempt.data.url)
|
||||
},
|
||||
refresh: async () => {
|
||||
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
||||
await queryClient.refetchQueries(queryOptionsApi.mcpResources(key))
|
||||
data.location.mcp.server.invalidate({ directory: key })
|
||||
data.location.mcp.resource.invalidate({ directory: key })
|
||||
await Promise.all([
|
||||
data.location.mcp.server.sync({ directory: key }),
|
||||
data.location.mcp.resource.sync({ directory: key }),
|
||||
])
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -799,25 +362,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createServerSyncContext(serverSDK: ServerSDK) {
|
||||
const inner = createServerSyncContextInner(serverSDK)
|
||||
return Object.assign(inner, {
|
||||
ensureDirSyncContext: createRefCountMap(
|
||||
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
||||
(dir) => inner.disableMcp(dir),
|
||||
directoryKey,
|
||||
),
|
||||
})
|
||||
export function createServerSyncContext(serverSDK: ServerSDK, data: Data) {
|
||||
return createServerSyncContextInner(serverSDK, data)
|
||||
}
|
||||
|
||||
export type ServerSync = ReturnType<typeof createServerSyncContext>
|
||||
|
||||
export const useServerSync = () => {
|
||||
const server = useServer()
|
||||
return server.ctx.sync
|
||||
}
|
||||
|
||||
export function useQueryOptions() {
|
||||
const sync = useServerSync()
|
||||
return sync.queryOptions
|
||||
}
|
||||
|
||||
@@ -22,3 +22,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const useData = () => {
|
||||
const server = useServer()
|
||||
return server.ctx.data
|
||||
}
|
||||
|
||||
@@ -1,12 +1,108 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { Message, Part } from "@/types"
|
||||
import { messageKey } from "@/utils/session-message"
|
||||
|
||||
export const useSync = () => {
|
||||
const serverSync = useServerSync()
|
||||
const sdk = useSDK()
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
return createMemo(() => serverSync.ensureDirSyncContext(sdk().directory))
|
||||
function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
export type DirectorySync = ReturnType<ReturnType<typeof useSync>>
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
type OptimisticStore = {
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
}
|
||||
|
||||
type OptimisticAddInput = {
|
||||
sessionID: string
|
||||
message: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type OptimisticRemoveInput = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
}
|
||||
|
||||
type OptimisticItem = {
|
||||
message: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return want.length === 0
|
||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
||||
}
|
||||
|
||||
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return sortParts(want)
|
||||
const next = [...parts]
|
||||
let changed = false
|
||||
for (const part of want) {
|
||||
const result = Binary.search(next, part.id, (item) => item.id)
|
||||
if (result.found) continue
|
||||
next.splice(result.index, 0, part)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return parts
|
||||
return next
|
||||
}
|
||||
|
||||
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
||||
|
||||
const session = [...page.session]
|
||||
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
|
||||
const confirmed: string[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const result = Binary.search(session, messageKey(item.message), messageKey)
|
||||
const found = result.found
|
||||
if (!found) session.splice(result.index, 0, item.message)
|
||||
|
||||
const current = part.get(item.message.id)
|
||||
if (found && hasParts(current, item.parts)) {
|
||||
confirmed.push(item.message.id)
|
||||
continue
|
||||
}
|
||||
|
||||
part.set(item.message.id, mergeParts(current, item.parts))
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: page.cursor,
|
||||
complete: page.complete,
|
||||
session,
|
||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
|
||||
confirmed,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, messageKey(input.message), messageKey)
|
||||
messages.splice(result.index, 0, input.message)
|
||||
} else {
|
||||
draft.message[input.sessionID] = [input.message]
|
||||
}
|
||||
draft.part[input.message.id] = sortParts(input.parts)
|
||||
}
|
||||
|
||||
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (messages) {
|
||||
const index = messages.findIndex((message) => message.id === input.messageID)
|
||||
if (index >= 0) messages.splice(index, 1)
|
||||
}
|
||||
delete draft.part[input.messageID]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK, type DirectorySDK } from "./sdk"
|
||||
import { useWorkspaceLocation, type LocationContext } from "./location"
|
||||
import type { Platform } from "./platform"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
@@ -144,7 +144,8 @@ export function clearWorkspaceTerminals(
|
||||
}
|
||||
|
||||
function createWorkspaceTerminalSession(
|
||||
sdk: DirectorySDK,
|
||||
sdk: LocationContext,
|
||||
serverSDK: ReturnType<typeof useServerSDK>,
|
||||
dir: string,
|
||||
scope: ServerScopeValue,
|
||||
legacySessionID?: string,
|
||||
@@ -248,7 +249,7 @@ function createWorkspaceTerminalSession(
|
||||
setStore("all", index, (item) => ({ ...item, ...pty }))
|
||||
}
|
||||
const doUpdate = async () => {
|
||||
await sdk.api.pty.update({
|
||||
await serverSDK.api.pty.update({
|
||||
ptyID: pty.id,
|
||||
location,
|
||||
title: pty.title,
|
||||
@@ -268,7 +269,7 @@ function createWorkspaceTerminalSession(
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
const data = await sdk.api.pty
|
||||
const data = await serverSDK.api.pty
|
||||
.create({ location, title: pty.title })
|
||||
.then((result) => result.data)
|
||||
.catch((error: unknown) => {
|
||||
@@ -311,7 +312,7 @@ function createWorkspaceTerminalSession(
|
||||
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
|
||||
|
||||
const doCreate = async () => {
|
||||
return sdk.api.pty.create({ location, title: defaultTitle(nextNumber) }).then((result) => result.data)
|
||||
return serverSDK.api.pty.create({ location, title: defaultTitle(nextNumber) }).then((result) => result.data)
|
||||
}
|
||||
doCreate()
|
||||
.then((data) => {
|
||||
@@ -415,7 +416,7 @@ function createWorkspaceTerminalSession(
|
||||
})
|
||||
}
|
||||
|
||||
await sdk.api.pty.remove({ ptyID: id, location }).catch((error: unknown) => {
|
||||
await serverSDK.api.pty.remove({ ptyID: id, location }).catch((error: unknown) => {
|
||||
console.error("Failed to close terminal", error)
|
||||
})
|
||||
},
|
||||
@@ -436,7 +437,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
name: "Terminal",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const params = useParams()
|
||||
const cache = new Map<string, TerminalCacheEntry>()
|
||||
@@ -476,7 +477,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
}
|
||||
|
||||
const entry = createRoot((dispose) => ({
|
||||
value: createWorkspaceTerminalSession(sdk(), dir, serverScope, legacySessionID),
|
||||
value: createWorkspaceTerminalSession(sdk(), serverSDK, dir, serverScope, legacySessionID),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { createQuery } from "@tanstack/solid-query"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { useData } from "@/context/server"
|
||||
import { createEffect, type Accessor } from "solid-js"
|
||||
|
||||
export function useIntegrations(directory: Accessor<string | undefined>) {
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const query = createQuery(() => {
|
||||
const data = useData()
|
||||
|
||||
createEffect(() => {
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
const value = directory()
|
||||
return {
|
||||
...serverSync.queryOptions.integrations(value ? pathKey(value) : null),
|
||||
enabled: serverSDK.connection.status() === "connected",
|
||||
}
|
||||
void (async () => {
|
||||
const ref = value ? { directory: value } : undefined
|
||||
if (!ref) await data.location.syncInfo()
|
||||
await data.location.integration.sync(ref ?? data.location.default())
|
||||
})().catch(() => undefined)
|
||||
})
|
||||
|
||||
return {
|
||||
list: () => (query.isSuccess || query.isRefetchError ? query.data : []),
|
||||
list: () => data.location.integration.list(directory() ? { directory: directory()! } : undefined) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { normalizeProviderList } from "@/context/global-sync/utils"
|
||||
import { Iterable, pipe } from "effect"
|
||||
import { type Accessor } from "solid-js"
|
||||
import { createEffect, createMemo, type Accessor } from "solid-js"
|
||||
import { emptyProviderCatalog } from "./provider-catalog"
|
||||
import { useIntegrations } from "./use-integrations"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
export const popularProviders = [
|
||||
"opencode",
|
||||
@@ -19,17 +19,37 @@ export const popularProviders = [
|
||||
const popularProviderSet = new Set(popularProviders)
|
||||
|
||||
export function useProviders(directory: Accessor<string | undefined>) {
|
||||
const providersQuery = useQuery(() => {
|
||||
const queryOpts = useQueryOptions()
|
||||
const data = useData()
|
||||
const sdk = useServerSDK()
|
||||
const location = () => {
|
||||
const dir = directory()
|
||||
return queryOpts.providers(dir ? pathKey(dir) : null)
|
||||
return dir ? { directory: dir } : undefined
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (sdk.connection.status() !== "connected") return
|
||||
const ref = location()
|
||||
void (async () => {
|
||||
if (!ref) await data.location.syncInfo()
|
||||
const resolved = ref ?? data.location.default()
|
||||
await Promise.all([data.location.provider.sync(resolved), data.location.model.sync(resolved)])
|
||||
})().catch(() => undefined)
|
||||
})
|
||||
const integrations = useIntegrations(directory)
|
||||
|
||||
const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data)
|
||||
const providers = createMemo(() => {
|
||||
const ref = location()
|
||||
const provider = data.location.provider.list(ref)
|
||||
const model = data.location.model.list(ref)
|
||||
if (!provider || !model) return emptyProviderCatalog
|
||||
return normalizeProviderList(provider, model)
|
||||
})
|
||||
|
||||
return {
|
||||
ready: () => providersQuery.isSuccess,
|
||||
ready: () => {
|
||||
const ref = location()
|
||||
return data.location.provider.list(ref) !== undefined && data.location.model.list(ref) !== undefined
|
||||
},
|
||||
all: () => providers().all,
|
||||
default: () => providers().default,
|
||||
// V2 servers list only available providers, so the connectable catalog
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { AppBaseProviders, AppInterface } from "./app"
|
||||
export { useLayout } from "./context/layout"
|
||||
export { useServerSDK } from "./context/server-sdk"
|
||||
export { useServerSync } from "./context/server-sync"
|
||||
export { useServers as useServers } from "./context/servers"
|
||||
export { useSettings } from "./context/settings"
|
||||
export { useTabs } from "./context/tabs"
|
||||
|
||||
@@ -1,125 +1,50 @@
|
||||
import { DataProvider } from "@opencode-ai/session-ui/context"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { createMemo, type ParentProps } from "solid-js"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Schema } from "effect"
|
||||
import type { ServerConnection } from "@/context/servers"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
|
||||
export function DirectoryDataProvider(
|
||||
export function SessionUIProvider(
|
||||
props: ParentProps<{
|
||||
directory: string
|
||||
draftID?: string
|
||||
server?: ServerConnection.Key
|
||||
server: ServerConnection.Key
|
||||
}>,
|
||||
) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
const directory = () => props.directory
|
||||
const slug = createMemo(() => base64Encode(directory()))
|
||||
const href = (sessionID: string) => {
|
||||
if (props.server) return sessionHref(props.server, sessionID)
|
||||
return `/${slug()}/session/${sessionID}`
|
||||
}
|
||||
const href = (sessionID: string) => sessionHref(props.server, sessionID)
|
||||
const navigateToSession = async (sessionID: string) => {
|
||||
const session = serverSync.session
|
||||
await Promise.allSettled([session.lineage.resolve(sessionID), session.sync(sessionID)])
|
||||
await data.session.sync(sessionID).catch(() => undefined)
|
||||
navigate(href(sessionID))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
|
||||
if (props.draftID || props.server) return
|
||||
const next = sync().data.path.directory
|
||||
if (!next || next === directory()) return
|
||||
const path = location.pathname.slice(slug().length + 1)
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
})
|
||||
|
||||
createResource(
|
||||
() => params.id,
|
||||
(id) => serverSync.session.hydrate(id).catch(() => {}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
serverSync.session.pin(sessionID)
|
||||
onCleanup(() => serverSync.session.unpin(sessionID))
|
||||
})
|
||||
const legacyData = createMemo(() => ({
|
||||
session: data.session.list(),
|
||||
session_status: Object.fromEntries(
|
||||
data.session
|
||||
.list()
|
||||
.map((session) => [
|
||||
session.id,
|
||||
data.session.status(session.id) === "running" ? ({ type: "busy" } as const) : ({ type: "idle" } as const),
|
||||
]),
|
||||
),
|
||||
session_diff: {},
|
||||
message: {},
|
||||
part: {},
|
||||
}))
|
||||
|
||||
return (
|
||||
<Show when={directory()} keyed>
|
||||
{(directory) => (
|
||||
<DataProvider
|
||||
data={sync().data}
|
||||
directory={directory}
|
||||
sessionID={params.id}
|
||||
onNavigateToSession={navigateToSession}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProjectDirString = Schema.String.pipe(Schema.brand("ProjectDirString"))
|
||||
export type ProjectDirString = Schema.Schema.Type<typeof ProjectDirString>
|
||||
|
||||
export function decodeDirectory(dir: string): ProjectDirString | undefined {
|
||||
const decoded = decode64(dir)
|
||||
if (!decoded) return
|
||||
return ProjectDirString.make(decoded)
|
||||
}
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
let invalid = ""
|
||||
|
||||
const resolved = createMemo(() => {
|
||||
if (!params.dir) return ""
|
||||
return decodeDirectory(params.dir) ?? ""
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const dir = params.dir
|
||||
if (!dir) return
|
||||
if (resolved()) {
|
||||
invalid = ""
|
||||
return
|
||||
}
|
||||
if (invalid === dir) return
|
||||
invalid = dir
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.t("directory.error.invalidUrl"),
|
||||
})
|
||||
navigate("/", { replace: true })
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={resolved()} keyed>
|
||||
{(resolved) => (
|
||||
<SDKProvider directory={resolved}>
|
||||
<DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
)}
|
||||
</Show>
|
||||
<DataProvider
|
||||
data={legacyData()}
|
||||
directory={directory()}
|
||||
sessionID={params.id}
|
||||
onNavigateToSession={navigateToSession}
|
||||
onSessionHref={href}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { skipToken, useQuery } from "@tanstack/solid-query"
|
||||
import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
|
||||
import { produce } from "solid-js/store"
|
||||
import { useCommand } from "@/context/command"
|
||||
import {
|
||||
homeSessionIndexKey,
|
||||
loadHomeSessionIndex,
|
||||
parseHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
type HomeSessionEvents,
|
||||
} from "@/context/global-sync/home-session-index"
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -18,7 +18,6 @@ import { compareSessionTime, displayName, errorMessage, projectForSession } from
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { archiveHomeSession } from "../home-session-archive"
|
||||
import type { HomeController } from "./home-controller"
|
||||
|
||||
@@ -50,33 +49,23 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const projectByID = createMemo(
|
||||
() => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
)
|
||||
const homeSessions = () => home.server.focusedSync()?.homeSessions
|
||||
const sessionEventLoad = useQuery(() => {
|
||||
const cache = homeSessions()
|
||||
return {
|
||||
queryKey: cache?.eventsKey ?? (["home", "session-events", "unselected"] as const),
|
||||
queryFn: cache ? async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }) : skipToken,
|
||||
initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
|
||||
enabled: false,
|
||||
}
|
||||
})
|
||||
const sessionLoad = useQuery(() => {
|
||||
const cache = homeSessions()
|
||||
const ctx = home.server.focusedContext()
|
||||
const conn = home.server.focused()
|
||||
return {
|
||||
queryKey: cache?.indexKey ?? (["home", "session-index", "unselected"] as const),
|
||||
enabled: !!cache && home.server.focusedContext()?.sdk.connection.status() === "connected",
|
||||
queryKey: conn
|
||||
? homeSessionIndexKey(ServerConnection.key(conn))
|
||||
: (["home", "session-index", "unselected"] as const),
|
||||
enabled: !!ctx && ctx.sdk.connection.status() === "connected",
|
||||
queryFn:
|
||||
cache && home.server.focusedContext()
|
||||
ctx
|
||||
? async ({ signal }) => {
|
||||
const ctx = home.server.focusedContext()!
|
||||
const eventSequence = cache.eventSequence()
|
||||
const index = await loadHomeSessionIndex(
|
||||
(input, options) => ctx.sdk.api.session.list(input, options),
|
||||
eventSequence,
|
||||
signal,
|
||||
)
|
||||
cache.complete(eventSequence)
|
||||
return index
|
||||
index.forEach(ctx.data.session.remember)
|
||||
return Date.now()
|
||||
}
|
||||
: skipToken,
|
||||
retry: false,
|
||||
@@ -86,9 +75,9 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
}
|
||||
})
|
||||
const indexedSessions = createMemo(() => {
|
||||
const cache = homeSessions()
|
||||
if (!cache) return []
|
||||
return retainHomeSessions(cache.sessions(sessionLoad.data, sessionEventLoad.data), HOME_SESSION_LIMIT, Date.now())
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return []
|
||||
return retainHomeSessions(parseHomeSessionIndex(ctx.data.session.list()), HOME_SESSION_LIMIT, Date.now())
|
||||
})
|
||||
const allRecords = createMemo(() =>
|
||||
buildHomeSessionRecords({
|
||||
@@ -112,7 +101,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const key = `${ServerConnection.key(conn)}\0${record.session.id}`
|
||||
if (prefetched.has(key)) return
|
||||
prefetched.add(key)
|
||||
void untrack(() => ctx.sync.session.sync(record.session.id)).catch(() => {})
|
||||
void untrack(() => ctx.data.session.sync(record.session.id)).catch(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -179,7 +168,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const directory = project?.worktree ?? session.location.directory
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return
|
||||
ctx.sync.session.remember(session)
|
||||
ctx.data.session.remember(session)
|
||||
ctx.projects.open(directory)
|
||||
if (options?.background) {
|
||||
tabs.addSessionTab({ server: connKey, sessionId: session.id })
|
||||
@@ -195,19 +184,12 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const conn = home.server.focused()
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!conn || !ctx) return
|
||||
const [, setStore] = ctx.sync.child(session.location.directory)
|
||||
await archiveHomeSession({
|
||||
server: ServerConnection.key(conn),
|
||||
session,
|
||||
// TODO: Restore archiving when the V2 client exposes a session archive API.
|
||||
archive: async (_sessionID) => Promise.reject(new Error("Session archiving is unavailable")),
|
||||
remove: () =>
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, session.id, (item) => item.id)
|
||||
if (match.found) draft.session.splice(match.index, 1)
|
||||
}),
|
||||
),
|
||||
remove() {},
|
||||
onError: (cause) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
|
||||
@@ -11,32 +11,28 @@ export function useSessionTabAvatarState(
|
||||
const global = useGlobal()
|
||||
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
||||
const serverCtx = useServerCtx(connection)
|
||||
const sync = () => serverCtx()?.sync
|
||||
const hasPermissions = createMemo(() => {
|
||||
const ctx = serverCtx()
|
||||
if (!ctx) return false
|
||||
const sync = ctx.sync
|
||||
const permission = ctx.permission
|
||||
const [store] = sync.child(directory(), { bootstrap: false })
|
||||
return !!sessionPermissionRequest(store.session, sync.session.data.permission, sessionId(), (item) => {
|
||||
return !!sessionPermissionRequest(ctx.data.session.list(), ctx.data.session.permission.list, sessionId(), (item) => {
|
||||
return !permission.autoResponds(item, directory())
|
||||
})
|
||||
})
|
||||
const hasQuestions = createMemo(() => {
|
||||
const serverSync = sync()
|
||||
if (!serverSync) return false
|
||||
const [store] = serverSync.child(directory(), { bootstrap: false })
|
||||
return !!sessionQuestionForm(store.session, serverSync.session.data.form, sessionId())
|
||||
const data = serverCtx()?.data
|
||||
if (!data) return false
|
||||
return !!sessionQuestionForm(data.session.list(), data.session.form.list, sessionId())
|
||||
})
|
||||
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
|
||||
const unread = createMemo(
|
||||
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
|
||||
)
|
||||
const loading = createMemo(() => {
|
||||
const serverSync = sync()
|
||||
if (!serverSync) return false
|
||||
const data = serverCtx()?.data
|
||||
if (!data) return false
|
||||
if (needsAttention()) return false
|
||||
return serverSync.session.data.session_working(sessionId())
|
||||
return data.session.status(sessionId()) === "running"
|
||||
})
|
||||
return { unread, loading }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
||||
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
@@ -17,7 +16,6 @@ export function createNewSessionDraftController(props: {
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
const prompt = usePrompt()
|
||||
const serverSync = useServerSync()
|
||||
const comments = useComments()
|
||||
const local = useLocal()
|
||||
const route = useSessionKey()
|
||||
@@ -29,7 +27,6 @@ export function createNewSessionDraftController(props: {
|
||||
const controls = createPromptInputController({
|
||||
sessionKey: route.sessionKey,
|
||||
sessionID: () => route.params.id,
|
||||
queryOptions: serverSync.queryOptions,
|
||||
model,
|
||||
})
|
||||
const projectControls = createPromptProjectControls({
|
||||
|
||||
@@ -16,8 +16,7 @@ import {
|
||||
} from "@/components/prompt-project-selector"
|
||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -110,8 +109,7 @@ export function NewSessionStatus(props: { mount: HTMLElement | null; visible: bo
|
||||
function ProviderTip() {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const serverSync = useServerSync()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
||||
Persist.global("new-session.provider-tip"),
|
||||
@@ -119,7 +117,7 @@ function ProviderTip() {
|
||||
)
|
||||
const visible = createMemo(
|
||||
() =>
|
||||
serverSync.child(sdk().directory)[0].provider_ready &&
|
||||
providers.ready() &&
|
||||
persistedReady() &&
|
||||
providers.paid().length === 0 &&
|
||||
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||
import {
|
||||
isWorkspaceDirectory,
|
||||
isWorkspaceSelection,
|
||||
@@ -48,23 +48,29 @@ export function createNewSessionWorkspaceController(input: {
|
||||
setSelected: (worktree: string | undefined) => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const settings = useSettings()
|
||||
const localVcs = createMemo(() => serverSync.child(sdk().directory)[0].vcs)
|
||||
const currentProject = createMemo(() => {
|
||||
const projectID = data.location.info({ directory: sdk().directory })?.project.id
|
||||
const current = projectID ? data.project.get(projectID) : undefined
|
||||
return current ? normalizeProjectInfo(current) : undefined
|
||||
})
|
||||
const visible = createMemo(() =>
|
||||
resolveNewSessionGit({ projectVcs: sync().project?.vcs, branch: localVcs()?.branch }),
|
||||
resolveNewSessionGit({
|
||||
projectVcs: currentProject()?.vcs,
|
||||
branch: data.location.vcs.info({ directory: sdk().directory })?.branch.current,
|
||||
}),
|
||||
)
|
||||
const selected = createMemo(() => {
|
||||
const project = sync().project
|
||||
const project = currentProject()
|
||||
const worktree = input.selected()
|
||||
if (!project || !worktree) return
|
||||
return isWorkspaceSelection(project, worktree) ? worktree : undefined
|
||||
})
|
||||
const fallback = createMemo(() => {
|
||||
const project = sync().project
|
||||
const project = currentProject()
|
||||
if (!project) return "main"
|
||||
return workspaceDefaultSelection(
|
||||
settings.workspaces.defaultDestination(),
|
||||
@@ -76,21 +82,27 @@ export function createNewSessionWorkspaceController(input: {
|
||||
enabled: visible(),
|
||||
selected: selected(),
|
||||
directory: sdk().directory,
|
||||
projectWorktree: sync().project?.worktree,
|
||||
projectWorktree: currentProject()?.worktree,
|
||||
fallback: fallback(),
|
||||
}),
|
||||
)
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
const localBranch = createMemo(() => serverSync.child(projectRoot())[0].vcs?.branch)
|
||||
const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory)
|
||||
createEffect(() => {
|
||||
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(() => undefined)
|
||||
const project = currentProject()
|
||||
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
|
||||
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
|
||||
})
|
||||
const localBranch = createMemo(() => data.location.vcs.info({ directory: projectRoot() })?.branch.current)
|
||||
const branch = createMemo(() =>
|
||||
resolveNewSessionBranch({
|
||||
worktree: value(),
|
||||
local: localBranch(),
|
||||
worktreeBranch: (worktree) => serverSync.child(worktree)[0].vcs?.branch,
|
||||
worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current,
|
||||
}),
|
||||
)
|
||||
const remember = (worktree = value()) => {
|
||||
const project = sync().project
|
||||
const project = currentProject()
|
||||
if (!project) return
|
||||
const local = worktree === "main" || sameDirectory(worktree, project.worktree)
|
||||
settings.workspaces.setLastUsed(serverSDK.scope, project.id, local ? "local" : "workspace")
|
||||
@@ -100,21 +112,21 @@ export function createNewSessionWorkspaceController(input: {
|
||||
selection: {
|
||||
value,
|
||||
workspace: createMemo(() => {
|
||||
const project = sync().project
|
||||
const project = currentProject()
|
||||
const current = value()
|
||||
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
|
||||
}),
|
||||
reset: () => input.setSelected(undefined),
|
||||
remember,
|
||||
set: (worktree: string) => {
|
||||
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree))
|
||||
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
|
||||
remember(worktree)
|
||||
},
|
||||
},
|
||||
project: {
|
||||
root: projectRoot,
|
||||
workspaces: () => {
|
||||
const project = sync().project
|
||||
const project = currentProject()
|
||||
return project ? workspaceDirectories(project) : []
|
||||
},
|
||||
git: visible,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FilePart, Project, UserMessage } from "@/types"
|
||||
import type { FilePart, UserMessage } from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -38,32 +38,31 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { isWorkspaceDirectory } from "@/utils/workspace"
|
||||
import { containsDirectory, isWorkspaceDirectory } from "@/utils/workspace"
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { ErrorPage } from "@/pages/error"
|
||||
import { CommentsProvider, useComments } from "@/context/comments"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { SessionUIProvider } from "@/pages/directory-layout"
|
||||
import { useData, useServer } from "@/context/server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { PromptProvider, usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { LocationProvider, useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerConnection, serverName, useServers } from "@/context/servers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
|
||||
import { promptLength } from "@/components/prompt-input/history"
|
||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||
import type { FollowupDraft } from "@/components/prompt-input/submit"
|
||||
import {
|
||||
createPromptInputController,
|
||||
createSessionComposerController,
|
||||
@@ -95,11 +94,10 @@ import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { diffs as list } from "@/utils/diffs"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
import { createSessionLineage } from "./session/session-lineage"
|
||||
import { createSessionResolution } from "./session/session-resolution"
|
||||
|
||||
type FollowupItem = FollowupDraft & { id: string }
|
||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||
@@ -118,37 +116,14 @@ function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undef
|
||||
return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
|
||||
}
|
||||
|
||||
async function runPromptRollbackMutation<T, R>(input: {
|
||||
capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
|
||||
optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
|
||||
request: () => Promise<R>
|
||||
complete: (result: R) => void
|
||||
rollback: () => void
|
||||
fail: (error: unknown) => void
|
||||
}) {
|
||||
const prompt = input.capturePrompt()
|
||||
const previous = prompt.current().slice()
|
||||
batch(() => input.optimistic(prompt))
|
||||
await input
|
||||
.request()
|
||||
.then(input.complete)
|
||||
.catch((error) => {
|
||||
batch(() => {
|
||||
input.rollback()
|
||||
prompt.set(previous)
|
||||
})
|
||||
input.fail(error)
|
||||
})
|
||||
}
|
||||
|
||||
// Rendered under app.tsx's TargetSessionRoute, which owns the per-server keyed
|
||||
// remount around the server-scoped providers. Nothing here may key on the
|
||||
// session ID: session tabs on the same server share this route instance, and
|
||||
// workspace-scoped state (terminal, directory providers) lives below.
|
||||
export function TargetSessionRouteContent() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const serverSync = useServerSync()
|
||||
const directory = createMemo(() => serverSync.session.lineage.peek(params.id)?.session.location.directory)
|
||||
const data = useData()
|
||||
const directory = createMemo(() => data.session.get(params.id)?.location.directory)
|
||||
return (
|
||||
// Settings must keep the target-server SDK, sync, and models context and remain registered
|
||||
// when session content falls back to the route error boundary.
|
||||
@@ -232,37 +207,28 @@ function SessionErrorFallback(props: { error: unknown; sessionID?: string; serve
|
||||
}
|
||||
|
||||
function ResolvedTargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const tabs = useTabs()
|
||||
const sync = useServerSync()
|
||||
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||
const current = createSessionLineage(
|
||||
const params = useParams<{ id: string }>()
|
||||
const server = useServer()
|
||||
const data = useData()
|
||||
const current = createSessionResolution(
|
||||
() => params.id,
|
||||
() => sync.session.lineage,
|
||||
() => data.session,
|
||||
{ children: true },
|
||||
)
|
||||
const directory = createMemo(() => current()?.session.location.directory)
|
||||
|
||||
createEffect(() => {
|
||||
const session = current()
|
||||
if (!session) return
|
||||
tabs.addSessionTab({
|
||||
server: serverKey(),
|
||||
sessionId: session.root.id,
|
||||
})
|
||||
})
|
||||
const directory = createMemo(() => current()?.location.directory)
|
||||
|
||||
return (
|
||||
// Non-keyed: closes only while the target's directory is unknown (uncached
|
||||
// lineage mid-resolution), which tears down the workspace subtree including
|
||||
// session mid-resolution), which tears down the workspace subtree including
|
||||
// the terminal. Same-workspace tab switches keep it open because warm
|
||||
// targets resolve synchronously from the sync cache.
|
||||
<Show when={directory()}>
|
||||
{(dir) => (
|
||||
<SDKProvider directory={dir()}>
|
||||
<DirectoryDataProvider directory={dir()} server={serverKey()}>
|
||||
<LocationProvider directory={dir()}>
|
||||
<SessionUIProvider directory={dir()} server={server.key}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</SessionUIProvider>
|
||||
</LocationProvider>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
@@ -272,10 +238,10 @@ function ResolvedTargetSessionRoute() {
|
||||
// key: SessionPage handles session changes reactively, and remounting here
|
||||
// destroys workspace-scoped state (terminal PTYs, file/prompt providers).
|
||||
function TargetSessionPage() {
|
||||
const sdk = useSDK()
|
||||
const location = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
return (
|
||||
<Show when={`${serverSDK.scope}\0${sdk().directory}`} keyed>
|
||||
<Show when={`${serverSDK.scope}\0${location().directory}`} keyed>
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
@@ -322,15 +288,14 @@ function SessionPanelFrame(props: ParentProps<{ raised?: boolean }>) {
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const file = useFile()
|
||||
const sync = useSync()
|
||||
const queryClient = useQueryClient()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
@@ -340,8 +305,17 @@ export default function Page() {
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const canReview = createMemo(() => !!sync().project)
|
||||
const project = createMemo(() => {
|
||||
const info = params.id ? data.session.get(params.id) : undefined
|
||||
const value = info?.projectID
|
||||
? data.project.get(info.projectID)
|
||||
: data.project.list().find((item) => containsDirectory(item.canonical, sdk().directory))
|
||||
if (!value) return
|
||||
return { ...value, worktree: value.canonical, worktrees: [] }
|
||||
})
|
||||
const canReview = createMemo(() => !!project())
|
||||
const controller = createSessionController({
|
||||
review: isDesktop,
|
||||
hasReview: canReview,
|
||||
@@ -376,7 +350,6 @@ export default function Page() {
|
||||
const inputController = createPromptInputController({
|
||||
sessionKey: controller.identity.sessionKey,
|
||||
sessionID: () => controller.identity.params.id,
|
||||
queryOptions: serverSync.queryOptions,
|
||||
})
|
||||
|
||||
const sessionPanelKey = createMemo(() =>
|
||||
@@ -446,7 +419,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const sessionDirectory = createMemo(() => controller.data.info()?.location.directory ?? sdk().directory)
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
|
||||
const timeline = createTimelineModel({ session: controller })
|
||||
const historyLoading = timeline.history.loading
|
||||
const historyMore = timeline.history.more
|
||||
@@ -533,10 +506,6 @@ export default function Page() {
|
||||
})
|
||||
|
||||
let reviewFrame: number | undefined
|
||||
let todoFrame: number | undefined
|
||||
let todoTimer: number | undefined
|
||||
let diffFrame: number | undefined
|
||||
let diffTimer: number | undefined
|
||||
|
||||
createComputed((prev) => {
|
||||
const open = desktopReviewOpen()
|
||||
@@ -551,17 +520,23 @@ export default function Page() {
|
||||
return open
|
||||
}, desktopReviewOpen())
|
||||
|
||||
const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs))
|
||||
// TODO: Restore turn diffs when current transcript projections expose message summaries.
|
||||
const turnDiffs = createMemo(() => list(undefined))
|
||||
const nogit = createMemo(() => {
|
||||
const project = sync().project
|
||||
return !!project && project.vcs !== "git"
|
||||
const current = project()
|
||||
return !!current && current.vcs !== "git"
|
||||
})
|
||||
const vcs = createMemo(() => data.location.vcs.info({ directory: sdk().directory }))
|
||||
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||
const list: ChangeMode[] = []
|
||||
const project = sync().project
|
||||
const vcs = sync().data.vcs
|
||||
if (project?.vcs === "git") list.push("git")
|
||||
if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) {
|
||||
const current = project()
|
||||
if (current?.vcs === "git") list.push("git")
|
||||
if (
|
||||
current?.vcs === "git" &&
|
||||
vcs()?.branch.current &&
|
||||
vcs()?.branch.default &&
|
||||
vcs()?.branch.current !== vcs()?.branch.default
|
||||
) {
|
||||
list.push("branch")
|
||||
}
|
||||
list.push("turn")
|
||||
@@ -584,13 +559,13 @@ export default function Page() {
|
||||
serverSDK.scope,
|
||||
"session-vcs",
|
||||
sdk().directory,
|
||||
sync().data.vcs?.branch ?? "",
|
||||
sync().data.vcs?.default_branch ?? "",
|
||||
vcs()?.branch.current ?? "",
|
||||
vcs()?.branch.default ?? "",
|
||||
] as const,
|
||||
)
|
||||
const vcsQuery = createQuery(() => {
|
||||
const mode = vcsMode()
|
||||
const enabled = serverSDK.connection.status() === "connected" && wantsReview() && sync().project?.vcs === "git"
|
||||
const enabled = serverSDK.connection.status() === "connected" && wantsReview() && project()?.vcs === "git"
|
||||
|
||||
return {
|
||||
queryKey: [...vcsKey(), mode] as const,
|
||||
@@ -599,18 +574,21 @@ export default function Page() {
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: mode
|
||||
? () =>
|
||||
sdk()
|
||||
.api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode })
|
||||
serverSDK.api.vcs
|
||||
.diff({
|
||||
location: { directory: sdk().directory },
|
||||
mode: mode === "git" ? "working" : mode,
|
||||
})
|
||||
.then((result) => result.data)
|
||||
: skipToken,
|
||||
}
|
||||
})
|
||||
const sessionDetailsQuery = createQuery(() => ({
|
||||
queryKey: [serverSDK.scope, "session-details", sessionDirectory()] as const,
|
||||
enabled: store.sessionDetailsOpen && serverSDK.connection.status() === "connected" && sync().project?.vcs === "git",
|
||||
enabled: store.sessionDetailsOpen && serverSDK.connection.status() === "connected" && project()?.vcs === "git",
|
||||
queryFn: () =>
|
||||
sdk()
|
||||
.api.vcs.diff({ location: { directory: sessionDirectory() }, mode: "working" })
|
||||
serverSDK.api.vcs
|
||||
.diff({ location: { directory: sessionDirectory() }, mode: "working" })
|
||||
.then((result) => result.data)
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load session details diff", { error })
|
||||
@@ -658,7 +636,7 @@ export default function Page() {
|
||||
const loadReviewDiff = async (file: string, version?: number): Promise<FileDiffInfo | undefined> => {
|
||||
const mode = vcsMode()
|
||||
if (!mode) return
|
||||
const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory)
|
||||
const root = reviewRootDirectory(project()?.worktree ?? sdk().directory)
|
||||
const directory = reviewDiffDirectory(root, file)
|
||||
const source = reviewDiffs().find((diff) => diff.file === file)
|
||||
const valid = (diff: FileDiffInfo | undefined) => {
|
||||
@@ -674,8 +652,8 @@ export default function Page() {
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
retry: 2,
|
||||
queryFn: () =>
|
||||
sdk()
|
||||
.api.vcs.diff({
|
||||
serverSDK.api.vcs
|
||||
.diff({
|
||||
location: { directory: scope },
|
||||
mode: mode === "git" ? "working" : mode,
|
||||
context,
|
||||
@@ -702,8 +680,8 @@ export default function Page() {
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
const project = sync().project
|
||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||
const current = project()
|
||||
if (current && sdk().directory !== current.worktree) return sdk().directory
|
||||
return "main"
|
||||
})
|
||||
|
||||
@@ -764,25 +742,6 @@ export default function Page() {
|
||||
scrollToMessage(msgs[targetIndex], "auto")
|
||||
}
|
||||
|
||||
function upsert(next: Project) {
|
||||
const list = serverSync.data.project
|
||||
sync().set("project", next.id)
|
||||
const idx = list.findIndex((item) => item.id === next.id)
|
||||
if (idx >= 0) {
|
||||
serverSync.set(
|
||||
"project",
|
||||
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
|
||||
)
|
||||
return
|
||||
}
|
||||
const at = list.findIndex((item) => item.id > next.id)
|
||||
if (at >= 0) {
|
||||
serverSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
|
||||
return
|
||||
}
|
||||
serverSync.set("project", [...list, next])
|
||||
}
|
||||
|
||||
const gitMutation = useMutation(() => ({
|
||||
// TODO: Restore Git initialization when the V2 client exposes this operation.
|
||||
mutationFn: async () => Promise.reject(new Error("Git initialization is unavailable")),
|
||||
@@ -825,40 +784,7 @@ export default function Page() {
|
||||
|
||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
const id = controller.identity.params.id
|
||||
return [
|
||||
sdk().directory,
|
||||
id,
|
||||
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
|
||||
id ? composer.blocked() : false,
|
||||
] as const
|
||||
},
|
||||
([dir, id, status, blocked]) => {
|
||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||
todoFrame = undefined
|
||||
todoTimer = undefined
|
||||
if (!id) return
|
||||
if (status === "idle" && !blocked) return
|
||||
const cached = untrack(() => sync().data.todo[id] !== undefined)
|
||||
|
||||
todoFrame = requestAnimationFrame(() => {
|
||||
todoFrame = undefined
|
||||
todoTimer = window.setTimeout(() => {
|
||||
todoTimer = undefined
|
||||
if (sdk().directory !== dir || controller.identity.params.id !== id) return
|
||||
untrack(() => {
|
||||
void sync().session.todo(id, cached ? { force: true } : undefined)
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
// TODO: Restore todo refresh when todos are available from the current session API.
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
@@ -1005,8 +931,8 @@ export default function Page() {
|
||||
|
||||
createEffect(() => {
|
||||
if (!layout.ready()) return
|
||||
if (sync().status !== "complete") return
|
||||
if (!sync().project) return
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
if (!project()) return
|
||||
const list = changesOptions()
|
||||
const mode = reviewMode()
|
||||
if (list.includes(mode)) return
|
||||
@@ -1017,7 +943,7 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync().data.session_status[controller.identity.params.id ?? ""]?.type,
|
||||
() => data.session.status(controller.identity.params.id ?? ""),
|
||||
(next, prev) => {
|
||||
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||
refreshVcs()
|
||||
@@ -1380,7 +1306,7 @@ export default function Page() {
|
||||
const dir = sdk().directory
|
||||
if (!isDesktop()) return
|
||||
if (!layout.fileTree.opened()) return
|
||||
if (sync().status === "loading") return
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
|
||||
fileTreeTab()
|
||||
const refresh = treeDir !== dir
|
||||
@@ -1576,41 +1502,6 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
const draft = (id: string) =>
|
||||
extractPromptFromParts(sync().data.part[id] ?? [], {
|
||||
directory: sdk().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
|
||||
const line = (id: string) => {
|
||||
const text = draft(id)
|
||||
.map((part) => (part.type === "image" ? `[image:${part.filename}]` : part.content))
|
||||
.join("")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
if (text) return text
|
||||
return `[${language.t("common.attachment")}]`
|
||||
}
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
}
|
||||
|
||||
const roll = (
|
||||
sessionID: string,
|
||||
next: NonNullable<ReturnType<typeof controller.data.info>>["revert"],
|
||||
target = sync(),
|
||||
) => {
|
||||
const session = target.session.get(sessionID)
|
||||
if (!session) return
|
||||
target.session.remember({ ...session, revert: next })
|
||||
}
|
||||
|
||||
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
||||
const queuedFollowups = createMemo(() => {
|
||||
const id = controller.identity.params.id
|
||||
if (!id) return emptyFollowups
|
||||
@@ -1631,29 +1522,13 @@ export default function Page() {
|
||||
|
||||
const followupMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
||||
const owner = controller.ownership.capture()
|
||||
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
||||
if (!item) return
|
||||
if (!(followup.items[input.sessionID] ?? []).some((entry) => entry.id === input.id)) return
|
||||
|
||||
if (input.manual) setFollowup("paused", input.sessionID, undefined)
|
||||
setFollowup("failed", input.sessionID, undefined)
|
||||
|
||||
const ok = await sendFollowupDraft({
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync,
|
||||
session: () => sync().session.get(input.sessionID),
|
||||
draft: item,
|
||||
optimisticBusy: item.sessionDirectory === sdk().directory,
|
||||
}).catch((err) => {
|
||||
setFollowup("failed", input.sessionID, input.id)
|
||||
fail(err)
|
||||
return false
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
|
||||
if (input.manual) owner.run(resumeScroll)
|
||||
// TODO: Restore queued followups once submission no longer requires the legacy sync transcript.
|
||||
setFollowup("failed", input.sessionID, input.id)
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1670,12 +1545,8 @@ export default function Page() {
|
||||
const queueEnabled = createMemo(() => {
|
||||
const id = controller.identity.params.id
|
||||
if (!id) return false
|
||||
return (
|
||||
settings.general.followup() === "queue" &&
|
||||
controller.data.working() &&
|
||||
!composer.blocked() &&
|
||||
!controller.data.isChild()
|
||||
)
|
||||
// TODO: Restore queued followups with current session admission APIs.
|
||||
return false
|
||||
})
|
||||
|
||||
const followupText = (item: FollowupDraft) => {
|
||||
@@ -1707,7 +1578,7 @@ export default function Page() {
|
||||
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
|
||||
|
||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
|
||||
if (data.session.get(sessionID)?.parentID) return Promise.resolve()
|
||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||
if (!item) return Promise.resolve()
|
||||
if (followupBusy(sessionID)) return Promise.resolve()
|
||||
@@ -1738,88 +1609,12 @@ export default function Page() {
|
||||
setFollowup("edit", id, undefined)
|
||||
}
|
||||
|
||||
const halt = (sessionID: string) =>
|
||||
busy(sessionID)
|
||||
? sdk()
|
||||
.api.session.interrupt({ sessionID })
|
||||
.catch(() => {})
|
||||
: Promise.resolve()
|
||||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
const api = sdk().api.session
|
||||
const target = sync()
|
||||
const last = target.session.get(input.sessionID)?.revert
|
||||
const value = draft(input.messageID)
|
||||
await runPromptRollbackMutation({
|
||||
capturePrompt: prompt.capture,
|
||||
optimistic: (prompt) => {
|
||||
roll(input.sessionID, { messageID: input.messageID }, target)
|
||||
prompt.set(value)
|
||||
},
|
||||
request: () => halt(input.sessionID).then(() => api.revert.stage(input)),
|
||||
complete: () => undefined,
|
||||
rollback: () => roll(input.sessionID, last, target),
|
||||
fail,
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const restoreMutation = useMutation(() => ({
|
||||
mutationFn: async (id: string) => {
|
||||
const sessionID = controller.identity.params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const api = sdk().api.session
|
||||
const target = sync()
|
||||
const index = userMessages().findIndex((item) => item.id === id)
|
||||
if (index < 0) return
|
||||
const next = userMessages()[index + 1]
|
||||
const last = target.session.get(sessionID)?.revert
|
||||
|
||||
await runPromptRollbackMutation({
|
||||
capturePrompt: prompt.capture,
|
||||
optimistic: (promptSession) => {
|
||||
roll(sessionID, next ? { messageID: next.id } : undefined, target)
|
||||
if (next) {
|
||||
promptSession.set(draft(next.id))
|
||||
return
|
||||
}
|
||||
promptSession.reset()
|
||||
},
|
||||
request: () =>
|
||||
!next
|
||||
? halt(sessionID).then(() => api.revert.clear({ sessionID }))
|
||||
: halt(sessionID).then(() => api.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)),
|
||||
complete: () => undefined,
|
||||
rollback: () => roll(sessionID, last, target),
|
||||
fail,
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
|
||||
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
||||
|
||||
const revert = (input: { sessionID: string; messageID: string }) => {
|
||||
if (reverting()) return
|
||||
return revertMutation.mutateAsync(input)
|
||||
}
|
||||
|
||||
const restore = (id: string) => {
|
||||
if (!controller.identity.params.id || reverting()) return
|
||||
return restoreMutation.mutateAsync(id)
|
||||
}
|
||||
|
||||
const rolled = createMemo(() => {
|
||||
const id = controller.data.revertMessageID()
|
||||
if (!id) return []
|
||||
const index = userMessages().findIndex((item) => item.id === id)
|
||||
if (index < 0) return []
|
||||
return userMessages()
|
||||
.slice(index)
|
||||
.map((item) => ({ id: item.id, text: line(item.id) }))
|
||||
})
|
||||
// TODO: Restore revert drafts when current transcript messages expose prompt parts.
|
||||
const reverting = () => false
|
||||
const restoring = () => undefined
|
||||
const revert = (_input: { sessionID: string; messageID: string }) => undefined
|
||||
const restore = (_id: string) => undefined
|
||||
const rolled = () => []
|
||||
|
||||
// attachment bytes are embedded as a data URL, so downloading always works;
|
||||
// revealing requires the on-disk path captured by the client that attached the file
|
||||
@@ -1891,7 +1686,8 @@ export default function Page() {
|
||||
visibleUserMessages,
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
|
||||
// TODO: Restore hash paging when the current message API exposes history cursors.
|
||||
loadMore: async () => undefined,
|
||||
currentMessageId: () => store.messageId,
|
||||
pendingMessage: () => ui.pendingMessage,
|
||||
setPendingMessage: (value) => setUi("pendingMessage", value),
|
||||
@@ -1925,10 +1721,6 @@ export default function Page() {
|
||||
|
||||
onCleanup(() => {
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
|
||||
})
|
||||
@@ -2057,10 +1849,6 @@ export default function Page() {
|
||||
prompt,
|
||||
ready: () => !store.deferRender && messagesReady(),
|
||||
centered,
|
||||
todo: {
|
||||
collapsed: () => controller.layout.view().todoCollapsed.get(),
|
||||
onToggle: () => controller.layout.view().todoCollapsed.set(!controller.layout.view().todoCollapsed.get()),
|
||||
},
|
||||
followup: () =>
|
||||
controller.identity.params.id && !controller.data.isChild()
|
||||
? {
|
||||
@@ -2084,9 +1872,12 @@ export default function Page() {
|
||||
const id = controller.data.parentID()
|
||||
if (!id) return
|
||||
navigate(
|
||||
controller.identity.params.serverKey
|
||||
? sessionHref(requireServerKey(controller.identity.params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id),
|
||||
sessionHref(
|
||||
controller.identity.params.serverKey
|
||||
? requireServerKey(controller.identity.params.serverKey)
|
||||
: ServerConnection.key(serverSDK.server),
|
||||
id,
|
||||
),
|
||||
)
|
||||
},
|
||||
setPromptRef: (el) => {
|
||||
|
||||
@@ -3,13 +3,11 @@ import { useModels } from "@/context/models"
|
||||
import type { ModelKey, ModelSelection } from "@/context/local"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
|
||||
export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const models = useModels()
|
||||
const prompt = usePrompt()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
@@ -21,18 +19,14 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model
|
||||
}
|
||||
|
||||
const configured = () => {
|
||||
const value = sync().data.config.model
|
||||
if (!value) return
|
||||
const [providerID, modelID] = value.split("/")
|
||||
const model = { providerID, modelID }
|
||||
if (valid(model)) return model
|
||||
// TODO: Restore the configured model fallback when current location data exposes config.
|
||||
return undefined
|
||||
}
|
||||
|
||||
const recent = () => models.recent.list().find(valid)
|
||||
const fallback = () => {
|
||||
const defaults = providers.default()
|
||||
return providers.connected().flatMap((provider) => {
|
||||
const modelID = defaults[provider.id] ?? Object.values(provider.models)[0]?.id
|
||||
const modelID = Object.values(provider.models)[0]?.id
|
||||
return modelID ? [{ providerID: provider.id, modelID }] : []
|
||||
})[0]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { createQuery } from "@tanstack/solid-query"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import type { PromptInputControls } from "@/components/prompt-input/contracts"
|
||||
@@ -8,43 +7,40 @@ import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useGlobal, useServerCtx } from "@/context/global"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal, type ModelSelection } from "@/context/local"
|
||||
import type { QueryOptionsApi } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { serverName, ServerConnection, useServers } from "@/context/servers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useData } from "@/context/server"
|
||||
import { normalizeAgentList } from "@/context/global-sync/utils"
|
||||
|
||||
export function createPromptInputController(input: {
|
||||
sessionKey: Accessor<string>
|
||||
sessionID: Accessor<string | undefined>
|
||||
queryOptions: Pick<QueryOptionsApi, "agents">
|
||||
model?: ModelSelection
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const view = layout.view(input.sessionKey)
|
||||
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
|
||||
|
||||
return createMemo<PromptInputControls>(() => {
|
||||
return {
|
||||
agents: {
|
||||
available: sync().data.agent,
|
||||
available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []),
|
||||
options: local.agent.list().map((agent) => agent.name),
|
||||
current: local.agent.current()?.name ?? "",
|
||||
loading: agentsQuery.isLoading,
|
||||
loading: data.location.agent.list({ directory: sdk().directory }) === undefined,
|
||||
visible: local.agent.visible(),
|
||||
select: local.agent.set,
|
||||
},
|
||||
model: {
|
||||
selection: input.model ?? local.model,
|
||||
paid: providers.paid().length > 0,
|
||||
loading: (local.agent.visible() && agentsQuery.isLoading) || !providers.ready(),
|
||||
loading: (local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) || !providers.ready(),
|
||||
},
|
||||
session: {
|
||||
id: input.sessionID(),
|
||||
@@ -58,7 +54,7 @@ export function createPromptInputController(input: {
|
||||
export function createPromptProjectControls(props: { draftId: string }) {
|
||||
const server = useServers()
|
||||
const serverSDK = useServerSDK()
|
||||
const sdk = useSDK()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createEffect, createMemo, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export function SessionComposerPullout(props: {
|
||||
name: "todo" | "background"
|
||||
name: "background"
|
||||
label: JSX.Element
|
||||
ariaLabel: string
|
||||
preview?: string
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type Accessor, createEffect, createMemo, createResource } from "solid-js"
|
||||
import type { PromptInputState } from "@/components/prompt-input"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
import type { SessionComposerController } from "./session-composer-state"
|
||||
|
||||
@@ -28,10 +25,6 @@ export function createSessionComposerRegionController(input: {
|
||||
prompt: PromptInputState
|
||||
ready: Accessor<boolean>
|
||||
centered: Accessor<boolean>
|
||||
todo: {
|
||||
collapsed: Accessor<boolean>
|
||||
onToggle: () => void
|
||||
}
|
||||
followup: Accessor<SessionComposerFollowupDock | undefined>
|
||||
revert: Accessor<SessionComposerRevertDock | undefined>
|
||||
onResponseSubmit: () => void
|
||||
@@ -39,43 +32,7 @@ export function createSessionComposerRegionController(input: {
|
||||
setPromptRef: (el: HTMLDivElement) => void
|
||||
setDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const sync = useSync()
|
||||
const [store, setStore] = createStore({
|
||||
ready: input.ready() || input.state.dock(),
|
||||
height: 320,
|
||||
body: undefined as HTMLDivElement | undefined,
|
||||
})
|
||||
let timer: number | undefined
|
||||
let frame: number | undefined
|
||||
|
||||
const clear = () => {
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
timer = undefined
|
||||
frame = undefined
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
input.sessionKey()
|
||||
const ready = input.ready()
|
||||
const dock = input.state.dock()
|
||||
|
||||
clear()
|
||||
if (store.ready || (!ready && !dock)) return
|
||||
if (dock) {
|
||||
setStore("ready", true)
|
||||
return
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
timer = window.setTimeout(() => {
|
||||
setStore("ready", true)
|
||||
timer = undefined
|
||||
}, 140)
|
||||
})
|
||||
})
|
||||
|
||||
const data = useData()
|
||||
createEffect(() => {
|
||||
if (!input.prompt.ready()) return
|
||||
setSessionHandoff(input.sessionKey(), {
|
||||
@@ -92,27 +49,10 @@ export function createSessionComposerRegionController(input: {
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const el = store.body
|
||||
if (!el) return
|
||||
const update = () => setStore("height", el.getBoundingClientRect().height)
|
||||
createResizeObserver(el, update)
|
||||
update()
|
||||
})
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
const parentID = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
return id ? sync().session.get(id)?.parentID : undefined
|
||||
return id ? data.session.get(id)?.parentID : undefined
|
||||
})
|
||||
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
|
||||
const progress = useSpring(
|
||||
() => (open() ? 1 : 0),
|
||||
{ visualDuration: 0.3, bounce: 0 },
|
||||
() => `${input.sessionKey()}\0${store.ready}`,
|
||||
)
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||
const ready = Promise.resolve()
|
||||
const [promptReady] = createResource(
|
||||
() => input.prompt.ready.promise ?? ready,
|
||||
@@ -122,7 +62,6 @@ export function createSessionComposerRegionController(input: {
|
||||
return {
|
||||
state: input.state,
|
||||
centered: input.centered,
|
||||
todo: input.todo,
|
||||
followup: input.followup,
|
||||
revert: input.revert,
|
||||
onResponseSubmit: input.onResponseSubmit,
|
||||
@@ -134,11 +73,7 @@ export function createSessionComposerRegionController(input: {
|
||||
showComposer: () => !input.state.blocked() || !!parentID(),
|
||||
handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt,
|
||||
promptReady: () => input.prompt.ready() || promptReady(),
|
||||
dock: () => (store.ready && input.state.dock()) || value() > 0.001,
|
||||
dockProgress: value,
|
||||
dockHeight: () => Math.max(78, store.height),
|
||||
lift: () => (input.revert()?.items.length ? 18 : 36 * value()),
|
||||
setDockBodyRef: (el: HTMLDivElement) => setStore("body", el),
|
||||
lift: () => (input.revert()?.items.length ? 18 : 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SessionPermissionDock } from "@/pages/session/composer/session-permissi
|
||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
|
||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
import { SessionBackgroundDock } from "@/pages/session/composer/session-background-dock"
|
||||
import type { SessionComposerRegionController } from "./session-composer-region-controller"
|
||||
|
||||
@@ -57,28 +56,6 @@ export function SessionComposerRegion(props: {
|
||||
</Show>
|
||||
|
||||
<Show when={controller.showComposer()}>
|
||||
<Show when={controller.dock()}>
|
||||
<div
|
||||
classList={{
|
||||
"overflow-hidden": true,
|
||||
"pointer-events-none": controller.dockProgress() < 0.98,
|
||||
}}
|
||||
style={{
|
||||
"max-height": `${controller.dockHeight() * controller.dockProgress()}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={controller.setDockBodyRef}>
|
||||
<SessionTodoDock
|
||||
todos={controller.state.todos()}
|
||||
collapsed={controller.todo.collapsed()}
|
||||
onToggle={controller.todo.onToggle}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={controller.dockProgress()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={controller.promptReady()}
|
||||
fallback={
|
||||
@@ -97,7 +74,6 @@ export function SessionComposerRegion(props: {
|
||||
</Show>
|
||||
<div
|
||||
class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
|
||||
style={{ "margin-top": `${-36 * controller.dockProgress()}px` }}
|
||||
>
|
||||
{controller.handoffPrompt() || language.t("prompt.loading")}
|
||||
</div>
|
||||
@@ -106,11 +82,7 @@ export function SessionComposerRegion(props: {
|
||||
>
|
||||
<Show when={rolled()} keyed>
|
||||
{(revert) => (
|
||||
<div
|
||||
style={{
|
||||
"margin-top": `${-36 * controller.dockProgress()}px`,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<SessionRevertDock
|
||||
items={revert.items}
|
||||
restoring={revert.restoring}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { todoDockAtBoundary, todoState } from "./session-composer-state"
|
||||
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
@@ -115,35 +114,3 @@ describe("sessionQuestionForm", () => {
|
||||
expect(sessionQuestionForm(sessions, forms, "root")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("todoState", () => {
|
||||
test("hides when there are no todos", () => {
|
||||
expect(todoState({ count: 0, done: false, live: true })).toBe("hide")
|
||||
})
|
||||
|
||||
test("opens while the session is still working", () => {
|
||||
expect(todoState({ count: 2, done: false, live: true })).toBe("open")
|
||||
})
|
||||
|
||||
test("closes completed todos after a running turn", () => {
|
||||
expect(todoState({ count: 2, done: true, live: true })).toBe("close")
|
||||
})
|
||||
|
||||
test("clears stale todos when the turn ends", () => {
|
||||
expect(todoState({ count: 2, done: false, live: false })).toBe("clear")
|
||||
})
|
||||
|
||||
test("clears completed todos when the session is no longer live", () => {
|
||||
expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
|
||||
})
|
||||
})
|
||||
|
||||
describe("todoDockAtBoundary", () => {
|
||||
test("shows active todos when entering a session", () => {
|
||||
expect(todoDockAtBoundary("open")).toBe(true)
|
||||
})
|
||||
|
||||
test("hides completed todos when entering a session", () => {
|
||||
expect(todoDockAtBoundary("close")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,69 +1,35 @@
|
||||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@/types"
|
||||
import type { FormInfo, PermissionRequest } from "@opencode-ai/client/promise"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
|
||||
import { createQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
|
||||
export const todoState = (input: {
|
||||
count: number
|
||||
done: boolean
|
||||
live: boolean
|
||||
}): "hide" | "clear" | "open" | "close" => {
|
||||
if (input.count === 0) return "hide"
|
||||
if (!input.live) return "clear"
|
||||
if (!input.done) return "open"
|
||||
return "close"
|
||||
}
|
||||
|
||||
export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state === "open"
|
||||
import { useData } from "@/context/server"
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) {
|
||||
export function createSessionComposerController() {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const queryClient = useQueryClient()
|
||||
const data = useData()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const shellKey = () => [serverSDK.scope, sdk().directory, "shell"] as const
|
||||
const shells = createQuery(() => ({
|
||||
queryKey: shellKey(),
|
||||
enabled: !!params.id && serverSDK.connection.status() === "connected",
|
||||
queryFn: () =>
|
||||
sdk()
|
||||
.api.shell.list({ location: { directory: sdk().directory } })
|
||||
.then((result) => result.data ?? []),
|
||||
}))
|
||||
onCleanup(
|
||||
sdk().event.listen((event) => {
|
||||
if (
|
||||
event.details.type !== "shell.created" &&
|
||||
event.details.type !== "shell.exited" &&
|
||||
event.details.type !== "shell.deleted"
|
||||
)
|
||||
return
|
||||
void queryClient.invalidateQueries({ queryKey: shellKey(), exact: true })
|
||||
}),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (!params.id || serverSDK.connection.status() !== "connected") return
|
||||
void data.shell.sync({ directory: sdk().directory }).catch(() => undefined)
|
||||
})
|
||||
|
||||
const questionRequest = createMemo((): FormInfo | undefined => {
|
||||
return sessionQuestionForm(sync().data.session, serverSync.session.data.form, params.id)
|
||||
return sessionQuestionForm(data.session.list(), data.session.form.list, params.id)
|
||||
})
|
||||
|
||||
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||
return sessionPermissionRequest(sync().data.session, sync().data.permission, params.id, (item) => {
|
||||
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id, (item) => {
|
||||
return !permission.autoResponds(item, sdk().directory)
|
||||
})
|
||||
})
|
||||
@@ -74,26 +40,15 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
return !!permissionRequest() || !!questionRequest()
|
||||
})
|
||||
|
||||
const todos = createMemo((): Todo[] => {
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
return serverSync.session.data.todo[id] ?? []
|
||||
})
|
||||
|
||||
const done = createMemo(
|
||||
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||
)
|
||||
|
||||
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
|
||||
const primary = () => {
|
||||
const id = params.id
|
||||
return !!id && !serverSync.session.get(id)?.parentID
|
||||
return !!id && !data.session.get(id)?.parentID
|
||||
}
|
||||
const backgroundBlocking = createMemo(() => {
|
||||
if (!primary()) return []
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
const assistant = (serverSync.session.data.session_message[id] ?? []).findLast(
|
||||
const assistant = data.session.message.list(id).findLast(
|
||||
(message) => message.type === "assistant" && message.time.completed === undefined,
|
||||
)
|
||||
if (assistant?.type !== "assistant") return []
|
||||
@@ -116,7 +71,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
const blocking = backgroundBlocking()
|
||||
const messages = serverSync.session.data.session_message[id] ?? []
|
||||
const messages = data.session.message.list(id)
|
||||
const completed = new Set(
|
||||
messages.flatMap((message) => {
|
||||
if (message.type !== "synthetic") return []
|
||||
@@ -144,9 +99,9 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
]
|
||||
})
|
||||
})
|
||||
const active = Object.values(serverSync.session.data.info).flatMap((info) => {
|
||||
const active = data.session.list().flatMap((info) => {
|
||||
if (info?.parentID !== id) return []
|
||||
if ((serverSync.session.data.session_status[info.id]?.type ?? "idle") === "idle") return []
|
||||
if (data.session.status(info.id) === "idle") return []
|
||||
if (
|
||||
blocking.some(
|
||||
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
|
||||
@@ -171,7 +126,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
]
|
||||
})
|
||||
})
|
||||
const running = (shells.isSuccess || shells.isRefetchError ? shells.data : []).flatMap((shell) => {
|
||||
const running = data.shell.list({ directory: sdk().directory }).flatMap((shell) => {
|
||||
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
|
||||
if (
|
||||
blocking.some(
|
||||
@@ -189,8 +144,8 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
if (!primary()) return
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
await sdk()
|
||||
.api.session.background({ sessionID })
|
||||
await serverSDK.api.session
|
||||
.background({ sessionID })
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
@@ -200,11 +155,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
sessionID: params.id,
|
||||
responding: undefined as string | undefined,
|
||||
dock: todos().length > 0 && !done() && live(),
|
||||
closing: false,
|
||||
opening: false,
|
||||
})
|
||||
|
||||
const permissionResponding = createMemo(() => {
|
||||
@@ -219,8 +170,8 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
if (store.responding === perm.id) return
|
||||
|
||||
setStore("responding", perm.id)
|
||||
sdk()
|
||||
.api.permission.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response })
|
||||
serverSDK.api.permission
|
||||
.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response })
|
||||
.catch((err: unknown) => {
|
||||
const description = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description })
|
||||
@@ -230,99 +181,6 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
})
|
||||
}
|
||||
|
||||
let timer: number | undefined
|
||||
let raf: number | undefined
|
||||
|
||||
const closeMs = () => {
|
||||
const value = options?.closeMs
|
||||
if (typeof value === "function") return Math.max(0, value())
|
||||
if (typeof value === "number") return Math.max(0, value)
|
||||
return 400
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
setStore({ dock: false, closing: false })
|
||||
timer = undefined
|
||||
}, closeMs())
|
||||
}
|
||||
|
||||
// Keep stale turn todos from reopening if the model never clears them.
|
||||
const clear = () => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
sync().set("todo", id, [])
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [params.id, todos().length, done(), live()] as const,
|
||||
([id, count, complete, active], previous) => {
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
raf = undefined
|
||||
|
||||
const next = todoState({
|
||||
count,
|
||||
done: complete,
|
||||
live: active,
|
||||
})
|
||||
|
||||
if (!previous || previous[0] !== id) {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false })
|
||||
if (next === "clear") clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (next === "hide") {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
setStore({ dock: false, closing: false, opening: false })
|
||||
return
|
||||
}
|
||||
|
||||
if (next === "clear") {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (next === "open") {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
const hidden = !store.dock || store.closing
|
||||
setStore({ dock: true, closing: false })
|
||||
if (hidden) {
|
||||
setStore("opening", true)
|
||||
raf = requestAnimationFrame(() => {
|
||||
setStore("opening", false)
|
||||
raf = undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
setStore("opening", false)
|
||||
return
|
||||
}
|
||||
|
||||
setStore({ dock: true, opening: false, closing: true })
|
||||
if (!timer) scheduleClose()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (!timer) return
|
||||
window.clearTimeout(timer)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (!raf) return
|
||||
cancelAnimationFrame(raf)
|
||||
})
|
||||
|
||||
return {
|
||||
blocked,
|
||||
questionRequest,
|
||||
@@ -334,13 +192,6 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
move: moveToBackground,
|
||||
},
|
||||
decide,
|
||||
todos,
|
||||
dock: () =>
|
||||
store.sessionID === params.id
|
||||
? store.dock
|
||||
: todoDockAtBoundary(todoState({ count: todos().length, done: done(), live: live() })),
|
||||
closing: () => store.sessionID === params.id && store.closing,
|
||||
opening: () => store.sessionID === params.id && store.opening,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { FormAnswer, FormInfo, FormMultiselectField, FormStringField } from "@opencode-ai/client/promise"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
@@ -68,7 +67,6 @@ function Option(props: {
|
||||
}
|
||||
|
||||
export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => void }> = (props) => {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id)
|
||||
@@ -238,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
|
||||
const replyMutation = useMutation(() => ({
|
||||
mutationFn: (answer: FormAnswer) =>
|
||||
sdk().api.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
|
||||
serverSDK.api.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
@@ -250,7 +248,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
}))
|
||||
|
||||
const rejectMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk().api.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
|
||||
mutationFn: () => serverSDK.api.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/clie
|
||||
|
||||
function sessionTreeRequest<T>(
|
||||
session: SessionInfo[],
|
||||
request: Record<string, T[] | undefined>,
|
||||
request: Record<string, T[] | undefined> | ((sessionID: string) => T[] | undefined),
|
||||
sessionID?: string,
|
||||
include: (item: T) => boolean = () => true,
|
||||
) {
|
||||
@@ -28,14 +28,15 @@ function sessionTreeRequest<T>(
|
||||
}
|
||||
}
|
||||
|
||||
const id = ids.find((id) => request[id]?.some(include))
|
||||
const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
|
||||
const id = ids.find((id) => list(id)?.some(include))
|
||||
if (!id) return
|
||||
return request[id]?.find(include)
|
||||
return list(id)?.find(include)
|
||||
}
|
||||
|
||||
export function sessionPermissionRequest(
|
||||
session: SessionInfo[],
|
||||
request: Record<string, PermissionRequest[] | undefined>,
|
||||
request: Record<string, PermissionRequest[] | undefined> | ((sessionID: string) => PermissionRequest[] | undefined),
|
||||
sessionID?: string,
|
||||
include?: (item: PermissionRequest) => boolean,
|
||||
) {
|
||||
@@ -44,7 +45,7 @@ export function sessionPermissionRequest(
|
||||
|
||||
export function sessionQuestionForm(
|
||||
session: SessionInfo[],
|
||||
request: Record<string, FormInfo[] | undefined>,
|
||||
request: Record<string, FormInfo[] | undefined> | ((sessionID: string) => FormInfo[] | undefined),
|
||||
sessionID?: string,
|
||||
) {
|
||||
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import type { Todo } from "@/types"
|
||||
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
|
||||
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { Index, Match, Switch, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { SessionComposerPullout } from "./session-composer-pullout"
|
||||
|
||||
const doneToken = "\u0000done\u0000"
|
||||
const totalToken = "\u0000total\u0000"
|
||||
|
||||
function dot(status: Todo["status"]) {
|
||||
if (status !== "in_progress") return undefined
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="block"
|
||||
>
|
||||
<circle
|
||||
cx="6"
|
||||
cy="6"
|
||||
r="3"
|
||||
style={{
|
||||
animation: "var(--animate-pulse-scale)",
|
||||
"transform-origin": "center",
|
||||
"transform-box": "fill-box",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTodoDock(props: {
|
||||
todos: Todo[]
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
collapseLabel: string
|
||||
expandLabel: string
|
||||
dockProgress: number
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
|
||||
const total = createMemo(() => props.todos.length)
|
||||
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
|
||||
const label = createMemo(() => language.t("session.todo.progress", { done: done(), total: total() }))
|
||||
const progress = createMemo(() =>
|
||||
language
|
||||
.t("session.todo.progress", { done: doneToken, total: totalToken })
|
||||
.split(/(\u0000done\u0000|\u0000total\u0000)/),
|
||||
)
|
||||
|
||||
const active = createMemo(
|
||||
() =>
|
||||
props.todos.find((todo) => todo.status === "in_progress") ??
|
||||
props.todos.find((todo) => todo.status === "pending") ??
|
||||
props.todos.filter((todo) => todo.status === "completed").at(-1) ??
|
||||
props.todos[0],
|
||||
)
|
||||
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
return (
|
||||
<SessionComposerPullout
|
||||
name="todo"
|
||||
label={
|
||||
<Index each={progress()}>
|
||||
{(item) => (
|
||||
<Switch fallback={<span>{item()}</span>}>
|
||||
<Match when={item() === doneToken}>
|
||||
<AnimatedNumber value={done()} />
|
||||
</Match>
|
||||
<Match when={item() === totalToken}>
|
||||
<AnimatedNumber value={total()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</Index>
|
||||
}
|
||||
ariaLabel={label()}
|
||||
preview={props.collapsed ? preview() : undefined}
|
||||
collapsed={props.collapsed}
|
||||
onToggle={props.onToggle}
|
||||
collapseLabel={props.collapseLabel}
|
||||
expandLabel={props.expandLabel}
|
||||
dockProgress={props.dockProgress}
|
||||
>
|
||||
<TodoList todos={props.todos} />
|
||||
</SessionComposerPullout>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoList(props: { todos: Todo[] }) {
|
||||
const [store, setStore] = createStore({
|
||||
stuck: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<div
|
||||
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
|
||||
style={{ "overflow-anchor": "none" }}
|
||||
onScroll={(e) => {
|
||||
setStore("stuck", e.currentTarget.scrollTop > 0)
|
||||
}}
|
||||
>
|
||||
<Index each={props.todos}>
|
||||
{(todo) => (
|
||||
<Checkbox
|
||||
readOnly
|
||||
checked={todo().status === "completed"}
|
||||
indeterminate={todo().status === "in_progress"}
|
||||
data-in-progress={todo().status === "in_progress" ? "" : undefined}
|
||||
data-state={todo().status}
|
||||
icon={dot(todo().status)}
|
||||
style={{
|
||||
"--checkbox-align": "flex-start",
|
||||
"--checkbox-offset": "1px",
|
||||
transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
opacity: todo().status === "pending" ? "0.94" : "1",
|
||||
}}
|
||||
>
|
||||
<TextStrikethrough
|
||||
active={todo().status === "completed" || todo().status === "cancelled"}
|
||||
text={todo().content}
|
||||
class="text-14-regular min-w-0 break-words"
|
||||
style={{
|
||||
"line-height": "var(--line-height-normal)",
|
||||
transition:
|
||||
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
color:
|
||||
todo().status === "completed" || todo().status === "cancelled"
|
||||
? "var(--text-weak)"
|
||||
: "var(--text-strong)",
|
||||
opacity: todo().status === "pending" ? "0.92" : "1",
|
||||
}}
|
||||
/>
|
||||
</Checkbox>
|
||||
)}
|
||||
</Index>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
|
||||
style={{
|
||||
background: "linear-gradient(to bottom, var(--background-base), transparent)",
|
||||
opacity: store.stuck ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,624 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { createEffect, createMemo, For, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@/types"
|
||||
import { useServerSync } from "@/context/global-sync"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import {
|
||||
SessionComposerRegion,
|
||||
createSessionComposerController,
|
||||
createSessionComposerRegionController,
|
||||
} from "@/pages/session/composer"
|
||||
|
||||
export default {
|
||||
title: "UI/Todo Panel Motion",
|
||||
id: "components-todo-panel-motion",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `### Overview
|
||||
This playground renders the real session composer region from app code.
|
||||
|
||||
### Source path
|
||||
- \`packages/app/src/pages/session/composer/session-composer-region.tsx\`
|
||||
|
||||
### Includes
|
||||
- \`SessionTodoDock\` (real)
|
||||
- \`PromptInput\` (real)
|
||||
|
||||
No visual reimplementation layer is used for the dock/input stack.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const pool = [
|
||||
"Refactor ToolStatusTitle DOM measurement to offscreen global measurer (unconstrained by timeline layout)",
|
||||
"Remove inline measure nodes/CSS hooks and keep width morph behavior intact",
|
||||
"Run typechecks/tests and report what changed",
|
||||
"Verify reduced-motion behavior in timeline",
|
||||
"Review diff for animation edge cases",
|
||||
"Document rollout notes in PR description",
|
||||
"Check keyboard and screen reader semantics",
|
||||
"Add storybook controls for iteration speed",
|
||||
]
|
||||
|
||||
const btn = (accent?: boolean) =>
|
||||
({
|
||||
padding: "6px 14px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "13px",
|
||||
}) as const
|
||||
|
||||
const controls = {
|
||||
agents: { available: [], options: ["build"], current: "build", loading: false, visible: true, select: () => {} },
|
||||
model: {
|
||||
selection: {
|
||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
||||
variant: { list: () => [], current: () => undefined, set: () => {} },
|
||||
},
|
||||
paid: true,
|
||||
loading: false,
|
||||
},
|
||||
session: {
|
||||
id: "story-session",
|
||||
tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
|
||||
reviewPanel: { opened: () => false, open: () => {} },
|
||||
},
|
||||
}
|
||||
|
||||
const css = `
|
||||
[data-component="todo-stage"] {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
[data-component="todo-preview"] {
|
||||
height: 560px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
[data-component="todo-session-root"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
[data-component="todo-session-frame"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-component="todo-session-panel"] {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-stronger);
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-content"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-scroll"] {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-spacer"] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-msg"] {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-weak);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
[data-slot="todo-preview-msg"][data-strong="true"] {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
`
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const global = useServerSync()
|
||||
const prompt = usePrompt()
|
||||
const [cfg, setCfg] = createStore({
|
||||
open: true,
|
||||
collapsed: false,
|
||||
step: 1,
|
||||
dockOpenDuration: 0.3,
|
||||
dockOpenBounce: 0,
|
||||
dockCloseDuration: 0.3,
|
||||
dockCloseBounce: 0,
|
||||
drawerExpandDuration: 0.3,
|
||||
drawerExpandBounce: 0,
|
||||
drawerCollapseDuration: 0.3,
|
||||
drawerCollapseBounce: 0,
|
||||
subtitleDuration: 600,
|
||||
subtitleAuto: true,
|
||||
subtitleTravel: 25,
|
||||
subtitleEdge: 17,
|
||||
countDuration: 600,
|
||||
countMask: 18,
|
||||
countMaskHeight: 0,
|
||||
countWidthDuration: 560,
|
||||
})
|
||||
const open = () => cfg.open
|
||||
const step = () => cfg.step
|
||||
const dockOpenDuration = () => cfg.dockOpenDuration
|
||||
const dockOpenBounce = () => cfg.dockOpenBounce
|
||||
const dockCloseDuration = () => cfg.dockCloseDuration
|
||||
const dockCloseBounce = () => cfg.dockCloseBounce
|
||||
const drawerExpandDuration = () => cfg.drawerExpandDuration
|
||||
const drawerExpandBounce = () => cfg.drawerExpandBounce
|
||||
const drawerCollapseDuration = () => cfg.drawerCollapseDuration
|
||||
const drawerCollapseBounce = () => cfg.drawerCollapseBounce
|
||||
const subtitleDuration = () => cfg.subtitleDuration
|
||||
const subtitleAuto = () => cfg.subtitleAuto
|
||||
const subtitleTravel = () => cfg.subtitleTravel
|
||||
const subtitleEdge = () => cfg.subtitleEdge
|
||||
const countDuration = () => cfg.countDuration
|
||||
const countMask = () => cfg.countMask
|
||||
const countMaskHeight = () => cfg.countMaskHeight
|
||||
const countWidthDuration = () => cfg.countWidthDuration
|
||||
const state = createSessionComposerController({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
|
||||
let frame
|
||||
let scrollRef
|
||||
|
||||
const todos = createMemo<Todo[]>(() => {
|
||||
const done = Math.max(0, Math.min(3, step()))
|
||||
return pool.slice(0, 3).map((content, i) => ({
|
||||
id: `todo-${i + 1}`,
|
||||
content,
|
||||
status: i < done ? "completed" : i === done && done < 3 ? "in_progress" : "pending",
|
||||
}))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
global.todo.set("story-session", todos())
|
||||
})
|
||||
|
||||
const clear = () => {
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
|
||||
const pin = () => {
|
||||
if (!scrollRef) return
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
}
|
||||
|
||||
const collapsed = () => cfg.collapsed
|
||||
const setCollapsed = (value: boolean) => setCfg("collapsed", value)
|
||||
const openDock = () => {
|
||||
clear()
|
||||
setCfg("open", true)
|
||||
frame = requestAnimationFrame(() => {
|
||||
pin()
|
||||
frame = undefined
|
||||
})
|
||||
}
|
||||
|
||||
const closeDock = () => {
|
||||
clear()
|
||||
setCfg("open", false)
|
||||
}
|
||||
|
||||
const dockOpen = () => open()
|
||||
|
||||
const toggleDock = () => {
|
||||
if (dockOpen()) {
|
||||
closeDock()
|
||||
return
|
||||
}
|
||||
openDock()
|
||||
}
|
||||
|
||||
const toggleDrawer = () => {
|
||||
if (!dockOpen()) {
|
||||
openDock()
|
||||
frame = requestAnimationFrame(() => {
|
||||
pin()
|
||||
setCollapsed(true)
|
||||
frame = undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
setCollapsed(!collapsed())
|
||||
}
|
||||
|
||||
const cycle = () => {
|
||||
setCfg("step", (value) => (value + 1) % 4)
|
||||
}
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
return (
|
||||
<div data-component="todo-stage">
|
||||
<style>{css}</style>
|
||||
|
||||
<div data-component="todo-preview">
|
||||
<div data-component="todo-session-root">
|
||||
<div data-component="todo-session-frame">
|
||||
<div data-component="todo-session-panel">
|
||||
<div data-slot="todo-preview-content">
|
||||
<div data-slot="todo-preview-scroll" class="scroll-view__viewport" ref={scrollRef}>
|
||||
<div data-slot="todo-preview-spacer" />
|
||||
<div data-slot="todo-preview-msg" data-strong="true">
|
||||
Thinking Checking type safety
|
||||
</div>
|
||||
<div data-slot="todo-preview-msg">Shell Prints five topic blocks between timed commands</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SessionComposerRegion
|
||||
controller={createSessionComposerRegionController({
|
||||
state,
|
||||
sessionKey: () => "story-session",
|
||||
sessionID: () => "story-session",
|
||||
prompt,
|
||||
ready: () => true,
|
||||
centered: () => false,
|
||||
todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) },
|
||||
followup: () => undefined,
|
||||
revert: () => undefined,
|
||||
onResponseSubmit: pin,
|
||||
openParent: () => {},
|
||||
setPromptRef: () => {},
|
||||
setDockRef: () => {},
|
||||
})}
|
||||
promptInput={
|
||||
<PromptInput
|
||||
controls={controls}
|
||||
submission={{ abort: () => {}, handleSubmit: (event) => event.preventDefault() }}
|
||||
ref={() => {}}
|
||||
newSessionWorktree=""
|
||||
onNewSessionWorktreeReset={() => {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={toggleDock} style={btn(dockOpen())}>
|
||||
{dockOpen() ? "Animate close" : "Animate open"}
|
||||
</button>
|
||||
<button onClick={toggleDrawer} style={btn(dockOpen() && collapsed())}>
|
||||
{dockOpen() && collapsed() ? "Expand todo dock" : "Collapse todo dock"}
|
||||
</button>
|
||||
<button onClick={cycle} style={btn(step() > 0)}>
|
||||
Cycle progress ({step()}/3 done)
|
||||
</button>
|
||||
<For each={[0, 1, 2, 3]}>
|
||||
{(value) => (
|
||||
<button onClick={() => setCfg("step", value)} style={btn(step() === value)}>
|
||||
{value} done
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: "10px", "max-width": "560px" }}>
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)" }}>Dock open</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockOpenDuration()}
|
||||
onInput={(event) => setCfg("dockOpenDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(dockOpenDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockOpenBounce()}
|
||||
onInput={(event) => setCfg("dockOpenBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{dockOpenBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Dock close
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockCloseDuration()}
|
||||
onInput={(event) => setCfg("dockCloseDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(dockCloseDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={dockCloseBounce()}
|
||||
onInput={(event) => setCfg("dockCloseBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{dockCloseBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Drawer expand
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerExpandDuration()}
|
||||
onInput={(event) => setCfg("drawerExpandDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(drawerExpandDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerExpandBounce()}
|
||||
onInput={(event) => setCfg("drawerExpandBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{drawerExpandBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Drawer collapse
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerCollapseDuration()}
|
||||
onInput={(event) => setCfg("drawerCollapseDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(drawerCollapseDuration() * 1000)}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
bounce
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={drawerCollapseBounce()}
|
||||
onInput={(event) => setCfg("drawerCollapseBounce", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{drawerCollapseBounce().toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Subtitle odometer
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="120"
|
||||
max="1400"
|
||||
step="10"
|
||||
value={subtitleDuration()}
|
||||
onInput={(event) => setCfg("subtitleDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(subtitleDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
auto fit
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subtitleAuto()}
|
||||
onInput={(event) => setCfg("subtitleAuto", event.currentTarget.checked)}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{subtitleAuto() ? "on" : "off"}
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
travel
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="40"
|
||||
step="1"
|
||||
value={subtitleTravel()}
|
||||
onInput={(event) => setCfg("subtitleTravel", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleTravel()}px</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
edge
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="40"
|
||||
step="1"
|
||||
value={subtitleEdge()}
|
||||
onInput={(event) => setCfg("subtitleEdge", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleEdge()}%</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
|
||||
Count odometer
|
||||
</div>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
duration
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="120"
|
||||
max="1400"
|
||||
step="10"
|
||||
value={countDuration()}
|
||||
onInput={(event) => setCfg("countDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(countDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
mask
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="4"
|
||||
max="40"
|
||||
step="1"
|
||||
value={countMask()}
|
||||
onInput={(event) => setCfg("countMask", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMask()}%</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
mask height
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="14"
|
||||
step="1"
|
||||
value={countMaskHeight()}
|
||||
onInput={(event) => setCfg("countMaskHeight", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMaskHeight()}px</span>
|
||||
</label>
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
|
||||
width spring
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1200"
|
||||
step="10"
|
||||
value={countWidthDuration()}
|
||||
onInput={(event) => setCfg("countWidthDuration", event.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
|
||||
{Math.round(countWidthDuration())}ms
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import type {
|
||||
SessionReviewCommentUpdate,
|
||||
} from "@opencode-ai/session-ui/session-review"
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import type { LineComment } from "@/context/comments"
|
||||
|
||||
@@ -49,12 +50,13 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
let userInteracted = false
|
||||
let restored: { x: number; y: number } | undefined
|
||||
|
||||
const sdk = useSDK()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
const layout = useLayout()
|
||||
|
||||
const readFile = async (path: string) => {
|
||||
return sdk()
|
||||
.api.file.read({ path, location: { directory: sdk().directory } })
|
||||
return serverSDK.api.file
|
||||
.read({ path, location: { directory: sdk().directory } })
|
||||
.then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) }))
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to read file", { path, error })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user