mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 21:17:50 +00:00
feat(app): add workspace flows to new layout
This commit is contained in:
parent
5e2a6257b2
commit
974271d6b1
52 changed files with 2807 additions and 410 deletions
419
packages/app/e2e/regression/workspaces.spec.ts
Normal file
419
packages/app/e2e/regression/workspaces.spec.ts
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const root = "C:/OpenCode/WorkspaceProject"
|
||||
const workspace = "C:/OpenCode/worktree/project/feature"
|
||||
const createdWorkspace = "C:/OpenCode/worktree/project/quick-contrast-fix"
|
||||
const project = {
|
||||
id: "proj_workspaces",
|
||||
worktree: root,
|
||||
vcs: "git" as const,
|
||||
name: "workspace-project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [workspace],
|
||||
}
|
||||
const provider = {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
}
|
||||
const diff = {
|
||||
file: "src/workspace.ts",
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
patch: "@@ -1 +1 @@\n-export const workspace = false\n+export const workspace = true",
|
||||
}
|
||||
|
||||
function userMessage(sessionID: string, id: string, text: string, withDiff = false) {
|
||||
return {
|
||||
info: {
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "test" },
|
||||
...(withDiff ? { summary: { diffs: [diff] } } : {}),
|
||||
},
|
||||
parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text }],
|
||||
}
|
||||
}
|
||||
|
||||
async function init(page: Page, tab: Record<string, unknown>) {
|
||||
await page.addInitScript(
|
||||
({ root, server, tab }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }),
|
||||
)
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ server, ...tab }]))
|
||||
},
|
||||
{ root, server, tab },
|
||||
)
|
||||
}
|
||||
|
||||
test("selects local, new, and existing workspaces from the ready-ish start menu", async ({ page }) => {
|
||||
const draftID = "draft_workspaces"
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
|
||||
|
||||
const trigger = page.getByRole("button", { name: /^local$/i })
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Local repository" })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "New workspace" })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "Workspace", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "View all" })).toHaveCount(0)
|
||||
|
||||
await page.getByRole("menuitem", { name: "New workspace" }).click()
|
||||
await expect(page.getByRole("button", { name: /New workspace/ })).toBeVisible()
|
||||
await expect(page.getByText("main", { exact: true })).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: /New workspace/ }).click()
|
||||
await page.getByRole("menuitem", { name: "Workspace", exact: true }).hover()
|
||||
await page.getByRole("menuitem", { name: "feature" }).click()
|
||||
await expect(page.getByRole("button", { name: /feature/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test("submits the owning prompt after a new workspace becomes ready", async ({ page }) => {
|
||||
const draftID = "draft_workspace_submit"
|
||||
const sessionID = "ses_workspace_submit"
|
||||
const session = {
|
||||
id: sessionID,
|
||||
slug: "workspace-submit",
|
||||
projectID: project.id,
|
||||
directory: createdWorkspace,
|
||||
title: "New session",
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
let prompt: unknown
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [session],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/experimental/worktree**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }),
|
||||
})
|
||||
})
|
||||
await page.route("**/session**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname !== "/session") return route.fallback()
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify(session),
|
||||
})
|
||||
})
|
||||
await page.route(`**/session/${sessionID}/prompt_async**`, async (route) => {
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||
})
|
||||
return
|
||||
}
|
||||
prompt = route.request().postDataJSON()
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
})
|
||||
await init(page, { type: "draft", draftID, directory: root })
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await transport.waitForConnection()
|
||||
await page.getByRole("button", { name: /^local$/i }).click()
|
||||
await page.getByRole("menuitem", { name: "New workspace" }).click()
|
||||
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||
await editor.fill("Build workspace support")
|
||||
await page.locator('[data-action="prompt-submit"]').click()
|
||||
|
||||
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
|
||||
await expect(lifecycle).toContainText("Creating workspace")
|
||||
for (const attempt of [1, 2, 3, 4, 5]) {
|
||||
await transport.send({
|
||||
directory: createdWorkspace,
|
||||
payload: {
|
||||
id: `evt_submit_ready_${attempt}`,
|
||||
type: "worktree.ready",
|
||||
properties: { name: "quick-contrast-fix" },
|
||||
},
|
||||
})
|
||||
await page.waitForTimeout(100)
|
||||
if (prompt) break
|
||||
}
|
||||
await expect.poll(() => prompt).not.toBeUndefined()
|
||||
await expect(lifecycle).toContainText("Workspace created")
|
||||
})
|
||||
|
||||
test("shows neutral workspace identity and the ready-ish session summary panel", async ({ page }) => {
|
||||
const sessionID = "ses_workspace_summary"
|
||||
const messageID = "msg_workspace_summary"
|
||||
const session = {
|
||||
id: sessionID,
|
||||
slug: "workspace-summary",
|
||||
projectID: project.id,
|
||||
directory: workspace,
|
||||
title: "Workspace summary session",
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
const vcsRequests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname === "/api/vcs/diff") vcsRequests.push(request.url())
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory: workspace,
|
||||
project,
|
||||
provider,
|
||||
sessions: [session],
|
||||
pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Implement workspace support", true)] }),
|
||||
vcsDiff: [diff],
|
||||
})
|
||||
await init(page, { type: "session", sessionId: sessionID })
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const timeline = page.locator("[data-workspace-session]")
|
||||
await expect(timeline).toBeVisible()
|
||||
await expect(timeline.locator(`[aria-label="${workspace}"]`)).toHaveAttribute("tabindex", "0")
|
||||
await expect(timeline.locator('[data-slot="session-title-child"]')).toHaveClass(/text-v2-text-text-base/)
|
||||
await expect(timeline.locator('[data-slot="user-message-text"]')).not.toHaveCSS(
|
||||
"background-color",
|
||||
"rgb(59, 92, 246)",
|
||||
)
|
||||
|
||||
const title = page.locator("[data-session-title]")
|
||||
await title.getByRole("button", { name: "Session details" }).click()
|
||||
const panel = page.locator('[data-component="session-summary-panel"]')
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel).toContainText("workspace-project")
|
||||
await expect(panel).toContainText("feature")
|
||||
await expect(panel).toContainText("1 Changed file")
|
||||
await expect.poll(() => vcsRequests.length).toBeGreaterThan(0)
|
||||
const request = new URL(vcsRequests.at(-1)!)
|
||||
expect(request.searchParams.get("mode")).toBe("working")
|
||||
expect(request.searchParams.get("location[directory]")).toBe(workspace)
|
||||
})
|
||||
|
||||
test("moves a changed local session to an existing workspace with an end-of-turn divider", async ({ page }) => {
|
||||
const sessionID = "ses_workspace_move_existing"
|
||||
const messageID = "msg_workspace_move_existing"
|
||||
const session = {
|
||||
id: sessionID,
|
||||
slug: "workspace-move-existing",
|
||||
projectID: project.id,
|
||||
directory: root,
|
||||
title: "Move this session",
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
let move: unknown
|
||||
let releaseMove = () => {}
|
||||
const moveGate = new Promise<void>((resolve) => {
|
||||
releaseMove = resolve
|
||||
})
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [session],
|
||||
pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Move this work", true)] }),
|
||||
vcsDiff: [diff],
|
||||
})
|
||||
await page.route("**/experimental/control-plane/move-session", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||
})
|
||||
return
|
||||
}
|
||||
move = route.request().postDataJSON()
|
||||
await moveGate
|
||||
session.directory = workspace
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: "null",
|
||||
})
|
||||
})
|
||||
await init(page, { type: "session", sessionId: sessionID })
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
const inlineMove = page
|
||||
.locator('[data-component="session-turn-diffs-group"]')
|
||||
.getByRole("button", { name: "Move to workspace" })
|
||||
await expect(inlineMove).toBeVisible()
|
||||
await inlineMove.click()
|
||||
await page.getByRole("menuitem", { name: "Workspace", exact: true }).hover()
|
||||
await page.getByRole("menuitem", { name: "feature" }).click()
|
||||
|
||||
await expect
|
||||
.poll(() => move)
|
||||
.toEqual({
|
||||
sessionID,
|
||||
destination: { directory: workspace },
|
||||
moveChanges: true,
|
||||
})
|
||||
await page.locator("[data-session-title]").getByRole("button", { name: "More options" }).click()
|
||||
await expect(page.getByRole("menuitem", { name: "Archive" })).toBeDisabled()
|
||||
await expect(page.getByRole("menuitem", { name: /Delete/ })).toBeDisabled()
|
||||
await page.keyboard.press("Escape")
|
||||
releaseMove()
|
||||
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
|
||||
await expect(lifecycle).toContainText("Workspace set")
|
||||
})
|
||||
|
||||
test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sessionID = "ses_workspace_move_new"
|
||||
const messageID = "msg_workspace_move_new"
|
||||
const session = {
|
||||
id: sessionID,
|
||||
slug: "workspace-move-new",
|
||||
projectID: project.id,
|
||||
directory: root,
|
||||
title: "Create a workspace",
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
let move: unknown
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: root,
|
||||
project,
|
||||
provider,
|
||||
sessions: [session],
|
||||
pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Create isolated workspace", true)] }),
|
||||
vcsDiff: [diff],
|
||||
})
|
||||
await page.route("**/experimental/worktree**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }),
|
||||
})
|
||||
})
|
||||
await page.route("**/experimental/control-plane/move-session", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
|
||||
})
|
||||
return
|
||||
}
|
||||
move = route.request().postDataJSON()
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: "null",
|
||||
})
|
||||
})
|
||||
await init(page, { type: "session", sessionId: sessionID })
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await page.locator("[data-session-title]").getByRole("button", { name: "Session details" }).click()
|
||||
await page
|
||||
.locator('[data-component="session-summary-panel"]')
|
||||
.getByRole("button", { name: "Move to workspace" })
|
||||
.click()
|
||||
await page.getByRole("menuitem", { name: "New workspace" }).click()
|
||||
|
||||
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
|
||||
await expect(lifecycle).toContainText("Creating workspace")
|
||||
for (const attempt of [1, 2, 3, 4, 5]) {
|
||||
await transport.send({
|
||||
directory: createdWorkspace,
|
||||
payload: {
|
||||
id: `evt_worktree_ready_${attempt}`,
|
||||
type: "worktree.ready",
|
||||
properties: { name: "quick-contrast-fix" },
|
||||
},
|
||||
})
|
||||
await page.waitForTimeout(100)
|
||||
if (move) break
|
||||
}
|
||||
await expect
|
||||
.poll(() => move)
|
||||
.toEqual({
|
||||
sessionID,
|
||||
destination: { directory: createdWorkspace },
|
||||
moveChanges: true,
|
||||
})
|
||||
await transport.send({
|
||||
directory: createdWorkspace,
|
||||
payload: {
|
||||
id: "evt_workspace_created",
|
||||
type: "session.next.moved",
|
||||
properties: {
|
||||
timestamp: Date.now(),
|
||||
sessionID,
|
||||
location: { directory: createdWorkspace },
|
||||
subdirectory: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
await expect(lifecycle).toContainText("Workspace created")
|
||||
})
|
||||
|
|
@ -8,9 +8,6 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
|||
import homeImage from "@/assets/help/home.png"
|
||||
import tabsImage from "@/assets/help/tabs.png"
|
||||
|
||||
// TODO: wire to changelog / seen-state when available
|
||||
const showPopover = () => true
|
||||
|
||||
// can remove this after the tabs rollout has been out for a while
|
||||
export function TabsInfoPopup() {
|
||||
const settings = useSettings()
|
||||
|
|
@ -135,11 +132,6 @@ export function TabsInfoPopup() {
|
|||
</p>
|
||||
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||
<p>When you reopen the app, your tabs are still open.</p>
|
||||
<p>
|
||||
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
|
||||
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
|
||||
will become permanent in a few weeks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
|||
import { createStore } from "solid-js/store"
|
||||
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
import { Worktree } from "@/utils/worktree"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
|
|
@ -26,12 +29,17 @@ const optimisticSeeded: boolean[] = []
|
|||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||
const sentShellDirectories: string[] = []
|
||||
const syncedDirectories: string[] = []
|
||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||
const sentPrompts: string[] = []
|
||||
const promptInputs: unknown[] = []
|
||||
const sentCommands: unknown[] = []
|
||||
const commands: Array<{ name: string }> = []
|
||||
const sentCommandDirectories: string[] = []
|
||||
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||
const syncedServers: string[] = []
|
||||
const optimisticServers: string[] = []
|
||||
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||
let serverSessionSyncs = 0
|
||||
|
||||
let params: { id?: string } = {}
|
||||
|
|
@ -40,6 +48,18 @@ let selected = "/repo/worktree-a"
|
|||
let variant: string | undefined
|
||||
let permissionServer = "server-a"
|
||||
let createSessionGate: Promise<void> | undefined
|
||||
let sessionFailure: Error | undefined
|
||||
let createWorktreeGate: Promise<void> | undefined
|
||||
let worktreeFailure: Error | undefined
|
||||
let worktreeHung = false
|
||||
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
|
||||
const draftServers: Record<string, string> = {}
|
||||
|
||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||
|
|
@ -67,16 +87,21 @@ const prompt = {
|
|||
replaceComments: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
capture: () => prompt,
|
||||
capture: (scope?: unknown, target?: unknown) => {
|
||||
promptCaptures.push({ scope, target })
|
||||
return prompt
|
||||
},
|
||||
}
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const clientFor = (directory: string) => {
|
||||
createdClients.push(directory)
|
||||
const clientFor = (directory: string, track = true) => {
|
||||
if (track) createdClients.push(directory)
|
||||
return {
|
||||
api: {
|
||||
session: {
|
||||
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||
await createSessionGate
|
||||
if (sessionFailure) throw sessionFailure
|
||||
const location = input.location?.directory ?? directory
|
||||
createdSessions.push(location)
|
||||
sessionCreateInputs.push(input)
|
||||
|
|
@ -99,9 +124,11 @@ const clientFor = (directory: string) => {
|
|||
},
|
||||
command: async (input: unknown) => {
|
||||
sentCommands.push(input)
|
||||
sentCommandDirectories.push(directory)
|
||||
},
|
||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||
sentShell.push(input)
|
||||
sentShellDirectories.push(directory)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -110,7 +137,16 @@ const clientFor = (directory: string) => {
|
|||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
worktree: {
|
||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
||||
create: async (_input: unknown, options?: { signal?: AbortSignal }) => {
|
||||
worktreeCreates++
|
||||
if (worktreeHung)
|
||||
return new Promise<never>((_, reject) => {
|
||||
options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true })
|
||||
})
|
||||
await createWorktreeGate
|
||||
if (worktreeFailure) throw worktreeFailure
|
||||
return { data: { directory: worktreeDirectory } }
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -173,7 +209,10 @@ beforeAll(async () => {
|
|||
|
||||
mock.module("@/context/tabs", () => ({
|
||||
useTabs: () => ({
|
||||
draft: () => ({ server: "project-server" }),
|
||||
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
|
||||
updateDraft: (draftID: string, draft: { worktree?: string }) => {
|
||||
updatedDrafts.push({ draftID, ...draft })
|
||||
},
|
||||
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
||||
promotedDrafts.push({ draftID, ...session })
|
||||
},
|
||||
|
|
@ -194,72 +233,81 @@ beforeAll(async () => {
|
|||
|
||||
mock.module("@/context/sdk", () => ({
|
||||
useSDK: () => {
|
||||
const sdk = {
|
||||
scope: "local",
|
||||
directory: "/repo/main",
|
||||
return () => ({
|
||||
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
||||
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
|
||||
client: rootClient,
|
||||
api: rootClient.api,
|
||||
url: "http://localhost:4096",
|
||||
createApi: (directory: string) => clientFor(directory, false).api,
|
||||
createClient(opts: any) {
|
||||
return clientFor(opts.directory)
|
||||
},
|
||||
}
|
||||
return () => sdk
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => () => ({
|
||||
data: { command: commands },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||
}) => {
|
||||
optimistic.push(value)
|
||||
optimisticSeeded.push(
|
||||
!!value.directory &&
|
||||
!!value.sessionID &&
|
||||
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
|
||||
)
|
||||
useSync: () => () => {
|
||||
const server = activeDirectorySync
|
||||
return {
|
||||
data: { command: commands },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||
}) => {
|
||||
optimisticServers.push(server)
|
||||
optimistic.push(value)
|
||||
optimisticSeeded.push(
|
||||
!!value.directory &&
|
||||
!!value.sessionID &&
|
||||
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
|
||||
)
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
},
|
||||
set: () => undefined,
|
||||
}),
|
||||
set: () => undefined,
|
||||
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/server-sync", () => ({
|
||||
useServerSync: () => () => ({
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedDirectories.push(directory)
|
||||
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 }>
|
||||
}
|
||||
useServerSync: () => () => {
|
||||
const server = activeServerSync
|
||||
return {
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
]
|
||||
},
|
||||
}),
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedServers.push(server)
|
||||
syncedDirectories.push(directory)
|
||||
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", () => ({
|
||||
|
|
@ -287,24 +335,204 @@ beforeEach(() => {
|
|||
optimisticSeeded.length = 0
|
||||
promoted.length = 0
|
||||
promotedDrafts.length = 0
|
||||
updatedDrafts.length = 0
|
||||
sentCommands.length = 0
|
||||
sentCommandDirectories.length = 0
|
||||
sentPrompts.length = 0
|
||||
promptInputs.length = 0
|
||||
sentCommands.length = 0
|
||||
commands.length = 0
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
syncedServers.length = 0
|
||||
optimisticServers.length = 0
|
||||
promptCaptures.length = 0
|
||||
params = {}
|
||||
search = {}
|
||||
sentShell.length = 0
|
||||
sentShellDirectories.length = 0
|
||||
syncedDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
permissionServer = "server-a"
|
||||
activeSDK = "server-a"
|
||||
activeServerSync = "server-a"
|
||||
activeDirectorySync = "server-a"
|
||||
commands = []
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
worktreeDirectory = `/repo/new-${++worktreeID}`
|
||||
createSessionGate = undefined
|
||||
serverSessionSyncs = 0
|
||||
sessionFailure = undefined
|
||||
createWorktreeGate = undefined
|
||||
worktreeFailure = undefined
|
||||
worktreeHung = false
|
||||
worktreeCreates = 0
|
||||
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
test("admits only one concurrent new-workspace submission", async () => {
|
||||
selected = "create"
|
||||
let release = () => {}
|
||||
createWorktreeGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
const first = submit.handleSubmit(event)
|
||||
const duplicate = submit.handleSubmit(event)
|
||||
expect(worktreeCreates).toBe(1)
|
||||
|
||||
release()
|
||||
await Promise.all([first, duplicate])
|
||||
expect(createdSessions).toEqual([worktreeDirectory])
|
||||
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||
await settle()
|
||||
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
})
|
||||
|
||||
test("allows retry after new-workspace submission fails", async () => {
|
||||
selected = "create"
|
||||
worktreeFailure = new Error("create failed")
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
await submit.handleSubmit(event)
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toEqual([])
|
||||
|
||||
worktreeFailure = undefined
|
||||
await submit.handleSubmit(event)
|
||||
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||
await settle()
|
||||
|
||||
expect(worktreeCreates).toBe(2)
|
||||
expect(createdSessions).toEqual([worktreeDirectory])
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
})
|
||||
|
||||
test("aborts a hung new-workspace request and allows retry", async () => {
|
||||
selected = "create"
|
||||
worktreeHung = true
|
||||
let resets = 0
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => resets++,
|
||||
onSubmit: () => undefined,
|
||||
worktreeRequestTimeoutMs: 1,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toEqual([])
|
||||
expect(selected).toBe("create")
|
||||
expect(promptValue).toEqual([{ type: "text", content: "ls", start: 0, end: 2 }])
|
||||
expect(resets).toBe(0)
|
||||
|
||||
worktreeHung = false
|
||||
await submit.handleSubmit(event)
|
||||
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||
await settle()
|
||||
|
||||
expect(worktreeCreates).toBe(2)
|
||||
expect(createdSessions).toEqual([worktreeDirectory])
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
expect(resets).toBe(1)
|
||||
})
|
||||
|
||||
test("retains workspace selection after session creation failure and clears it after retry", async () => {
|
||||
sessionFailure = new Error("session create failed")
|
||||
let resets = 0
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => {
|
||||
resets++
|
||||
selected = "main"
|
||||
},
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
await submit.handleSubmit(event)
|
||||
expect(resets).toBe(0)
|
||||
expect(selected).toBe("/repo/worktree-a")
|
||||
|
||||
sessionFailure = undefined
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(resets).toBe(1)
|
||||
expect(selected).toBe("main")
|
||||
})
|
||||
|
||||
test("reads the latest worktree accessor value per submit", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
|
|
@ -420,6 +648,54 @@ describe("prompt submit worktree selection", () => {
|
|||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
|
||||
test("keeps async submission effects bound to the initiating context", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
draftServers["draft-1"] = "project-server-a"
|
||||
draftServers["draft-2"] = "project-server-b"
|
||||
let release = () => {}
|
||||
createSessionGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
let submitted = 0
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => submitted++,
|
||||
})
|
||||
|
||||
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
activeSDK = "server-b"
|
||||
activeServerSync = "server-b"
|
||||
activeDirectorySync = "server-b"
|
||||
search.draftId = "draft-2"
|
||||
release()
|
||||
await result
|
||||
await settle()
|
||||
|
||||
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(optimisticServers).toEqual(["server-a"])
|
||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||
expect(WorkspaceOperation.get("server-b" as ServerScope, "session-1")).toBeUndefined()
|
||||
expect(submitted).toBe(0)
|
||||
})
|
||||
|
||||
test("promotes drafts using the selected project's server", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
const submit = createPromptSubmit({
|
||||
|
|
@ -448,7 +724,7 @@ describe("prompt submit worktree selection", () => {
|
|||
})
|
||||
|
||||
test("includes the selected variant on optimistic prompts", async () => {
|
||||
params = { id: "session-1" }
|
||||
params.id = "session-1"
|
||||
variant = "high"
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
|
|
@ -595,4 +871,154 @@ describe("prompt submit worktree selection", () => {
|
|||
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
||||
expect(optimisticSeeded).toEqual([true])
|
||||
})
|
||||
|
||||
test("waits for a new workspace before sending an initial shell", async () => {
|
||||
selected = "create"
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
expect(sentShell).toEqual([])
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
|
||||
|
||||
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||
await settle()
|
||||
|
||||
expect(sentShellDirectories).toEqual([worktreeDirectory])
|
||||
expect(sentShell[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
command: "ls",
|
||||
agent: "agent",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
})
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||
})
|
||||
|
||||
test("waits for a new workspace before sending an initial custom command", async () => {
|
||||
selected = "create"
|
||||
commands = [{ name: "deploy" }]
|
||||
promptValue = [{ type: "text", content: "/deploy now", start: 0, end: 11 }]
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
expect(sentCommands).toEqual([])
|
||||
|
||||
Worktree.ready(ServerScope.local, worktreeDirectory)
|
||||
await settle()
|
||||
|
||||
expect(sentCommands).toHaveLength(1)
|
||||
expect(sentCommands[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
command: "deploy",
|
||||
arguments: "now",
|
||||
agent: "agent",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
files: [],
|
||||
})
|
||||
expect(sentCommandDirectories).toEqual([worktreeDirectory])
|
||||
})
|
||||
|
||||
test("settles a pending workspace operation when the initial prompt is aborted", async () => {
|
||||
selected = "create"
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
|
||||
params = { id: "session-1" }
|
||||
|
||||
await submit.abort()
|
||||
await settle()
|
||||
|
||||
expect(sentPrompts).toEqual([])
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({
|
||||
status: "failed",
|
||||
message: "aborted",
|
||||
})
|
||||
})
|
||||
|
||||
test("settles a pending workspace operation when preparation fails", async () => {
|
||||
selected = "create"
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||
Worktree.failed(ServerScope.local, worktreeDirectory, "bootstrap failed")
|
||||
await settle()
|
||||
|
||||
expect(sentShell).toEqual([])
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({
|
||||
status: "failed",
|
||||
message: "bootstrap failed",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
|
|||
import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { WORKSPACE_PREPARATION_TIMEOUT_MS, workspaceRequestWithTimeout } from "@/utils/workspace-request"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
|
|
@ -26,9 +28,13 @@ import { Event } from "@opencode-ai/schema/event"
|
|||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
cleanup: VoidFunction
|
||||
scope: ServerScope
|
||||
sessionID: string
|
||||
serverSync: ServerSync
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingPrompt>()
|
||||
const submitting = new Set<string>()
|
||||
|
||||
export type FollowupDraft = {
|
||||
sessionID: string
|
||||
|
|
@ -42,6 +48,7 @@ export type FollowupDraft = {
|
|||
|
||||
type FollowupSendInput = {
|
||||
api: DirectorySDK["api"]["session"]
|
||||
scope: ServerScope
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
draft: FollowupDraft
|
||||
|
|
@ -55,6 +62,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
|
|||
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||
|
||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const operation = WorkspaceOperation.get(input.scope, input.draft.sessionID)
|
||||
if (operation?.status === "pending" && operation.messageID !== input.messageID) return false
|
||||
const text = draftText(input.draft.prompt)
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const setBusy = () => {
|
||||
|
|
@ -220,6 +229,7 @@ type PromptSubmitInput = {
|
|||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
model?: ModelSelection
|
||||
worktreeRequestTimeoutMs?: number
|
||||
}
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
|
@ -235,7 +245,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||
const pendingKey = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
|
||||
let pendingSubmission: { key: string; scope: ServerScope; sessionID: string } | undefined
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||
|
|
@ -248,18 +259,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
}
|
||||
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
const routeSessionID = params.id
|
||||
const owned =
|
||||
pendingSubmission && (!routeSessionID || routeSessionID === pendingSubmission.sessionID)
|
||||
? pending.get(pendingSubmission.key)
|
||||
: undefined
|
||||
const sessionID = routeSessionID ?? owned?.sessionID
|
||||
if (!sessionID) return Promise.resolve()
|
||||
|
||||
serverSync().session.set("todo", sessionID, [])
|
||||
;(owned?.serverSync ?? serverSync()).session.set("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
||||
const key = pendingKey(sessionID)
|
||||
const queued = pending.get(key)
|
||||
const key = owned ? pendingSubmission!.key : pendingKey(sdk().scope, sessionID)
|
||||
const queued = owned ?? pending.get(key)
|
||||
if (queued) {
|
||||
queued.abort.abort()
|
||||
queued.cleanup()
|
||||
WorkspaceOperation.fail(queued.scope, queued.sessionID, "aborted")
|
||||
pending.delete(key)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
|
@ -291,9 +307,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
}
|
||||
}
|
||||
|
||||
const seed = (dir: string, info: Session) => {
|
||||
serverSync().session.remember(info)
|
||||
const [, setStore] = serverSync().child(dir)
|
||||
const seed = (target: ServerSync, dir: string, info: Session) => {
|
||||
target.session.remember(info)
|
||||
const [, setStore] = target.child(dir)
|
||||
setStore("session", (list: Session[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
|
|
@ -325,6 +341,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
if (input.working()) void abort()
|
||||
return
|
||||
}
|
||||
if (params.id && WorkspaceOperation.get(sdk().scope, params.id)?.status === "pending") return
|
||||
|
||||
const modelSelection = input.model ?? local.model
|
||||
const currentModel = modelSelection.current()
|
||||
|
|
@ -338,293 +355,375 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
return
|
||||
}
|
||||
|
||||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
const projectDirectory = sdk().directory
|
||||
const submissionSDK = sdk()
|
||||
const submissionSync = sync()
|
||||
const submissionServerSync = serverSync()
|
||||
const submissionScope = submissionSDK.scope
|
||||
const projectDirectory = submissionSDK.directory
|
||||
const projectRoot = submissionSync.project?.worktree ?? projectDirectory
|
||||
const sessionID = params.id
|
||||
const isNewSession = !sessionID
|
||||
const currentSession = input.info()
|
||||
const draftID = search.draftId
|
||||
const draftServer = draftID ? tabs.draft(draftID).server : undefined
|
||||
const capturePrompt = prompt.capture
|
||||
const localSession = local.session
|
||||
const handoff = layout.handoff
|
||||
const resetWorktree = input.onNewSessionWorktreeReset
|
||||
const onSubmit = input.onSubmit
|
||||
const permissionState = permission.currentServerState()
|
||||
const isNewSession = !params.id
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
const submissionKey = ScopedKey.from(
|
||||
submissionScope,
|
||||
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
|
||||
)
|
||||
if (submitting.has(submissionKey)) return
|
||||
submitting.add(submissionKey)
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
let client = sdk().client
|
||||
try {
|
||||
input.addToHistory(currentPrompt, mode)
|
||||
input.resetHistoryNavigation()
|
||||
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await client.worktree
|
||||
.create({ directory: projectDirectory })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
let sessionDirectory = projectDirectory
|
||||
let client = submissionSDK.client
|
||||
let api = submissionSDK.api.session
|
||||
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await workspaceRequestWithTimeout(
|
||||
(signal) => client.worktree.create({ directory: projectDirectory }, { signal }),
|
||||
language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
input.worktreeRequestTimeoutMs ?? WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!createdWorktree?.directory) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: language.t("common.requestFailed"),
|
||||
})
|
||||
return
|
||||
}
|
||||
WorktreeState.pending(submissionScope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
client = submissionSDK.createClient({
|
||||
directory: sessionDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
api = submissionSDK.createApi(sessionDirectory).session
|
||||
submissionServerSync.child(sessionDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
let session = currentSession
|
||||
if (!session && isNewSession) {
|
||||
const created = await submissionSDK.api.session
|
||||
.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.then(normalizeSessionInfo)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!createdWorktree?.directory) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: language.t("common.requestFailed"),
|
||||
if (created) {
|
||||
seed(submissionServerSync, sessionDirectory, created)
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
|
||||
if (!draftID) resetWorktree?.()
|
||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||
localSession.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(
|
||||
capturePrompt(
|
||||
{ dir: base64Encode(sessionDirectory), id: session.id },
|
||||
{ server: draftServer, scope: submissionScope },
|
||||
),
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
||||
sessionDirectory = createdWorktree.directory
|
||||
}
|
||||
if (!session) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
client = sdk().createClient({
|
||||
directory: sessionDirectory,
|
||||
throwOnError: true,
|
||||
})
|
||||
serverSync().child(sessionDirectory)
|
||||
const clearInput = () => {
|
||||
submission.clear()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
|
||||
input.onNewSessionWorktreeReset?.()
|
||||
}
|
||||
|
||||
let session = input.info()
|
||||
if (!session && isNewSession) {
|
||||
const created = await sdk()
|
||||
.api.session.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.then(normalizeSessionInfo)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
if (created) {
|
||||
seed(sessionDirectory, created)
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||
local.session.promote(sessionDirectory, session.id, {
|
||||
agent: currentAgent.name,
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
const restoreInput = () => {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||
if (!submission.current(prompt.capture())) return true
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const model = {
|
||||
modelID: currentModel.id,
|
||||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
prompt: currentPrompt,
|
||||
context,
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext(submission.target())
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
|
||||
const clearInput = () => {
|
||||
submission.clear()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
const startWorkspaceOperation = (messageID: string) => {
|
||||
if (!isNewSession) return
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create" && sessionDirectory !== projectRoot) {
|
||||
WorkspaceOperation.start(submissionScope, session.id, "move", sessionDirectory, messageID)
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
}
|
||||
if (worktreeSelection !== "create") return
|
||||
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||
WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID)
|
||||
if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id)
|
||||
if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id, worktree.message)
|
||||
}
|
||||
|
||||
const restoreInput = () => {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||
if (!submission.current(prompt.capture())) return true
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
const waitForWorktree = async (cleanup: VoidFunction) => {
|
||||
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||
if (!worktree) return true
|
||||
if (worktree.status === "ready") {
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
return true
|
||||
}
|
||||
if (worktree.status === "failed") {
|
||||
WorkspaceOperation.fail(submissionScope, session.id, worktree.message)
|
||||
throw new Error(worktree.message)
|
||||
}
|
||||
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext(submission.target())
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
submissionSync.set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
|
||||
input.onSubmit?.()
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
sdk()
|
||||
.api.session.shell({
|
||||
const controller = new AbortController()
|
||||
const key = pendingKey(submissionScope, session.id)
|
||||
pendingSubmission = { key, scope: submissionScope, sessionID: session.id }
|
||||
pending.set(key, {
|
||||
abort: controller,
|
||||
cleanup,
|
||||
scope: submissionScope,
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
agent,
|
||||
model,
|
||||
serverSync: submissionServerSync,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
|
||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
if (controller.signal.aborted) {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
return
|
||||
}
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
|
||||
const timeoutMs = 5 * 60 * 1000
|
||||
const timer = { id: undefined as number | undefined }
|
||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
timer.id = window.setTimeout(() => {
|
||||
resolve({
|
||||
status: "failed",
|
||||
message: language.t("workspace.error.stillPreparing"),
|
||||
})
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
const result = await Promise.race([
|
||||
WorktreeState.wait(submissionScope, sessionDirectory),
|
||||
abortWait,
|
||||
timeout,
|
||||
]).finally(() => {
|
||||
pending.delete(key)
|
||||
if (pendingSubmission?.key === key) pendingSubmission = undefined
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") {
|
||||
WorkspaceOperation.fail(submissionScope, session.id, result.message)
|
||||
throw new Error(result.message)
|
||||
}
|
||||
WorkspaceOperation.complete(submissionScope, session.id)
|
||||
return true
|
||||
}
|
||||
|
||||
if (!draftID || search.draftId === draftID) onSubmit?.()
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
startWorkspaceOperation(eventID)
|
||||
void waitForWorktree(() => {
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||
sdk()
|
||||
.api.session.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
.then((ready) => {
|
||||
if (!ready) return
|
||||
return api.shell({
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
agent,
|
||||
model,
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
const messageID = Identifier.ascending("message")
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
sync().session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
||||
if (!worktree || worktree.status !== "pending") return true
|
||||
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "busy" })
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
startWorkspaceOperation(messageID)
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
||||
void waitForWorktree(() => {
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||
restoreInput()
|
||||
})
|
||||
.then((ready) => {
|
||||
if (!ready) return
|
||||
return api.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
const messageID = Identifier.ascending("message")
|
||||
startWorkspaceOperation(messageID)
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
submissionSync.session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
const cleanup = () => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
}
|
||||
|
||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
||||
|
||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
if (controller.signal.aborted) {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
return
|
||||
void sendFollowupDraft({
|
||||
api,
|
||||
scope: submissionScope,
|
||||
sync: submissionSync,
|
||||
serverSync: submissionServerSync,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
before: () => waitForWorktree(cleanup),
|
||||
}).catch((err) => {
|
||||
pending.delete(pendingKey(submissionScope, session.id))
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolve({ status: "failed", message: "aborted" })
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
|
||||
const timeoutMs = 5 * 60 * 1000
|
||||
const timer = { id: undefined as number | undefined }
|
||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
timer.id = window.setTimeout(() => {
|
||||
resolve({
|
||||
status: "failed",
|
||||
message: language.t("workspace.error.stillPreparing"),
|
||||
})
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
const result = await Promise.race([
|
||||
WorktreeState.wait(sdk().scope, sessionDirectory),
|
||||
abortWait,
|
||||
timeout,
|
||||
]).finally(() => {
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
pending.delete(pendingKey(session.id))
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") throw new Error(result.message)
|
||||
return true
|
||||
} finally {
|
||||
submitting.delete(submissionKey)
|
||||
}
|
||||
|
||||
void sendFollowupDraft({
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
before: waitForWorktree,
|
||||
}).catch((err) => {
|
||||
pending.delete(pendingKey(session.id))
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync().set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { For, Show } from "solid-js"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
|
|
@ -15,25 +14,33 @@ export function PromptWorkspaceSelector(props: {
|
|||
onDone: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
let pending: string | undefined
|
||||
const [search, setSearch] = createSignal("")
|
||||
let pending: { type: "select"; value: string } | undefined
|
||||
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
||||
const workspaces = createMemo(() => {
|
||||
const query = search().trim().toLowerCase()
|
||||
if (!query) return props.workspaces
|
||||
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
|
||||
})
|
||||
const icon = () => {
|
||||
if (selected() === "main") return "monitor"
|
||||
if (selected() === "create") return "workspace-new"
|
||||
return "workspace"
|
||||
return "workspace-isolated"
|
||||
}
|
||||
const select = (value: string) => {
|
||||
pending = value
|
||||
pending = { type: "select", value }
|
||||
}
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) return
|
||||
const value = pending
|
||||
if (open) {
|
||||
setSearch("")
|
||||
return
|
||||
}
|
||||
const action = pending
|
||||
pending = undefined
|
||||
if (value) props.onChange(value)
|
||||
if (action?.type === "select") props.onChange(action.value)
|
||||
props.onDone()
|
||||
}
|
||||
const label = () => {
|
||||
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
|
||||
if (selected() === "main") return language.t("workspace.type.local")
|
||||
if (props.value === "create") return language.t("workspace.new")
|
||||
return getFilename(props.value)
|
||||
}
|
||||
|
|
@ -42,9 +49,9 @@ export function PromptWorkspaceSelector(props: {
|
|||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
||||
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<MenuV2.Trigger class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
||||
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate capitalize">{label()}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
|
|
@ -52,14 +59,14 @@ export function PromptWorkspaceSelector(props: {
|
|||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item onSelect={() => select("main")}>
|
||||
<IconV2 name="monitor" />
|
||||
<Icon name="monitor" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
<Show when={selected() === "main"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => select("create")}>
|
||||
<IconV2 name="workspace-new" />
|
||||
<Icon name="workspace-new" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
|
|
@ -67,18 +74,40 @@ export function PromptWorkspaceSelector(props: {
|
|||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
<Show when={props.workspaces.length > 0}>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Separator class="h-[0.5px]" />
|
||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<MenuV2.SubTrigger>
|
||||
<IconV2 name="workspace" />
|
||||
{language.t("session.new.workspace.existing")}
|
||||
<Icon name="workspace" />
|
||||
{language.t("session.new.workspace.existing").replace(/…$/, "")}
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-w-[200px]">
|
||||
<For each={props.workspaces}>
|
||||
<MenuV2.SubContent class="w-[216px]">
|
||||
<Show when={props.workspaces.length >= 10}>
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
value={search()}
|
||||
placeholder={language.t("common.search.placeholder")}
|
||||
aria-label={language.t("common.search.placeholder")}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Escape" ||
|
||||
event.key === "ArrowDown" ||
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "Enter"
|
||||
)
|
||||
return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||
<IconV2 name="workspace-isolated" />
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
|
|
@ -102,6 +131,7 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
|||
const language = useLanguage()
|
||||
const label = () => {
|
||||
if (props.noGit) return language.t("session.new.git.none")
|
||||
if (!props.branch) return undefined
|
||||
return props.branch
|
||||
}
|
||||
|
||||
|
|
@ -116,8 +146,8 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
|||
class="min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 px-1.5 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name={props.noGit ? "monitor" : "branch"} size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{value()}</span>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
|
|
|
|||
192
packages/app/src/components/session-workspace-menu.tsx
Normal file
192
packages/app/src/components/session-workspace-menu.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { Worktree } from "@/utils/worktree"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import {
|
||||
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
workspaceRequestWithTimeout,
|
||||
} from "@/utils/workspace-request"
|
||||
|
||||
export function SessionWorkspaceMenu(props: {
|
||||
eligible: boolean
|
||||
sessionID: string
|
||||
project: Project
|
||||
directory: string
|
||||
messageID?: string
|
||||
placement?: ComponentProps<typeof MenuV2>["placement"]
|
||||
gutter?: number
|
||||
class?: string
|
||||
children: JSX.Element
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending"
|
||||
const blocked = () =>
|
||||
!props.eligible || operationPending() || serverSync().session.data.session_working(props.sessionID)
|
||||
const workspaces = () =>
|
||||
(props.project.sandboxes ?? []).filter((workspace) => pathKey(workspace) !== pathKey(props.directory))
|
||||
|
||||
const fail = (scope: ServerScope, sessionID: string, message: string) => {
|
||||
setStore("selected", undefined)
|
||||
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
|
||||
WorkspaceOperation.fail(scope, sessionID, message)
|
||||
showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message })
|
||||
}
|
||||
const move = async (selection: "create" | string) => {
|
||||
if (store.selected || blocked()) return
|
||||
const sdk = serverSDK()
|
||||
const sync = serverSync()
|
||||
const scope = sdk.scope
|
||||
const sessionID = props.sessionID
|
||||
const messageID = props.messageID
|
||||
const root = props.project.worktree
|
||||
const source = props.directory
|
||||
setStore("selected", selection)
|
||||
|
||||
const destination =
|
||||
selection === "create"
|
||||
? await createWorkspace(root, sessionID, messageID, sdk, (message) => fail(scope, sessionID, message), {
|
||||
createFailed: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
stillPreparing: language.t("workspace.error.stillPreparing"),
|
||||
})
|
||||
: selection
|
||||
if (!destination) return
|
||||
|
||||
WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID)
|
||||
if (sync.session.data.session_working(sessionID)) {
|
||||
fail(scope, sessionID, language.t("workspace.move.failed"))
|
||||
return
|
||||
}
|
||||
await workspaceRequestWithTimeout(
|
||||
(signal) =>
|
||||
sdk.client.experimental.controlPlane.moveSession(
|
||||
{
|
||||
sessionID,
|
||||
destination: { directory: destination },
|
||||
moveChanges: true,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
language.t("workspace.move.failed"),
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
.then(async () => {
|
||||
for (const attempt of Array.from({ length: 20 }, (_, index) => index)) {
|
||||
const session = await workspaceRequestWithTimeout(
|
||||
(signal) => sync.session.resolve(sessionID, { force: true, signal }),
|
||||
language.t("workspace.move.failed"),
|
||||
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||
).catch(() => undefined)
|
||||
if (session && pathKey(session.directory) === pathKey(destination)) {
|
||||
WorkspaceOperation.complete(scope, sessionID, destination)
|
||||
sync.reindexSession(sessionID, source)
|
||||
return
|
||||
}
|
||||
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(250 + attempt * 50, 1_000)))
|
||||
}
|
||||
fail(scope, sessionID, language.t("workspace.move.failed"))
|
||||
})
|
||||
.catch((error) =>
|
||||
fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed")),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuV2
|
||||
placement={props.placement ?? "bottom-end"}
|
||||
gutter={props.gutter ?? 4}
|
||||
modal={false}
|
||||
onOpenChange={props.onOpenChange}
|
||||
>
|
||||
<MenuV2.Trigger class={props.class} disabled={blocked()}>
|
||||
{props.children}
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class="w-[180px]">
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("workspace.move.title")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
|
||||
<Icon name="workspace-new" />
|
||||
{language.t("workspace.new")}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
<Show when={workspaces().length > 0}>
|
||||
<MenuV2.Separator class="h-[0.5px]" />
|
||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<MenuV2.SubTrigger>
|
||||
<Icon name="workspace" />
|
||||
{language.t("session.new.workspace.existing").replace(/…$/, "")}
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="w-[216px]">
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
</Show>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
)
|
||||
}
|
||||
|
||||
async function createWorkspace(
|
||||
root: string,
|
||||
sessionID: string,
|
||||
messageID: string | undefined,
|
||||
serverSDK: ReturnType<ReturnType<typeof useServerSDK>>,
|
||||
fail: (message: string) => void,
|
||||
messages: { createFailed: string; stillPreparing: string },
|
||||
) {
|
||||
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", root, messageID)
|
||||
const created = await workspaceRequestWithTimeout(
|
||||
(signal) => serverSDK.client.worktree.create({ directory: root }, { signal }),
|
||||
messages.createFailed,
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
.then((result) => result.data)
|
||||
.catch((error) => {
|
||||
fail(error instanceof Error ? error.message : messages.createFailed)
|
||||
return undefined
|
||||
})
|
||||
if (!created?.directory) return
|
||||
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", created.directory, messageID)
|
||||
Worktree.pending(serverSDK.scope, created.directory)
|
||||
const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
const timeout = new Promise<ReturnType<typeof Worktree.get>>((resolve) => {
|
||||
timer.id = setTimeout(
|
||||
() => resolve({ status: "failed", message: messages.stillPreparing }),
|
||||
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||
)
|
||||
})
|
||||
const ready = await Promise.race([Worktree.wait(serverSDK.scope, created.directory), timeout]).finally(() => {
|
||||
if (timer.id) clearTimeout(timer.id)
|
||||
})
|
||||
if (!ready || ready.status === "failed") {
|
||||
fail(ready?.message ?? messages.createFailed)
|
||||
return
|
||||
}
|
||||
return created.directory
|
||||
}
|
||||
|
|
@ -208,6 +208,57 @@ describe("applyDirectoryEvent", () => {
|
|||
expect(store.sessionTotal).toBe(2)
|
||||
})
|
||||
|
||||
test("moves a root between loaded directory indexes exactly once", () => {
|
||||
const info = { ...rootSession({ id: "session" }), directory: "/destination" }
|
||||
const [source, setSource] = createStore(
|
||||
baseState({ session: [{ ...info, directory: "/source" }], sessionTotal: 1 }),
|
||||
)
|
||||
const [destination, setDestination] = createStore(baseState({ session: [], sessionTotal: 0 }))
|
||||
const created = { type: "session.created", properties: { info } }
|
||||
const moved = {
|
||||
type: "session.moved",
|
||||
properties: { sessionID: info.id, location: { directory: info.directory } },
|
||||
}
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: created,
|
||||
store: destination,
|
||||
setStore: setDestination,
|
||||
push() {},
|
||||
directory: "/destination",
|
||||
loadLsp() {},
|
||||
})
|
||||
applyDirectoryEvent({
|
||||
event: moved,
|
||||
store: source,
|
||||
setStore: setSource,
|
||||
push() {},
|
||||
directory: "/source",
|
||||
loadLsp() {},
|
||||
})
|
||||
applyDirectoryEvent({
|
||||
event: created,
|
||||
store: destination,
|
||||
setStore: setDestination,
|
||||
push() {},
|
||||
directory: "/destination",
|
||||
loadLsp() {},
|
||||
})
|
||||
applyDirectoryEvent({
|
||||
event: moved,
|
||||
store: source,
|
||||
setStore: setSource,
|
||||
push() {},
|
||||
directory: "/source",
|
||||
loadLsp() {},
|
||||
})
|
||||
|
||||
expect(source.session).toEqual([])
|
||||
expect(source.sessionTotal).toBe(0)
|
||||
expect(destination.session.map((session) => session.id)).toEqual([info.id])
|
||||
expect(destination.sessionTotal).toBe(1)
|
||||
})
|
||||
|
||||
test("cleans session caches when archived", () => {
|
||||
const message = userMessage("msg_1", "ses_1")
|
||||
const [store, setStore] = createStore(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
|
|||
import { useSettings } from "./settings"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useTabs, type Tab } from "./tabs"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import {
|
||||
createPromptReady,
|
||||
createPromptSession,
|
||||
|
|
@ -104,11 +105,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
||||
const scope = (): PromptScope =>
|
||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||
const load = (scope: PromptScope) => {
|
||||
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
||||
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
|
||||
const current = settings.general.newLayoutDesigns()
|
||||
? selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
|
||||
: undefined
|
||||
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope)
|
||||
|
||||
const key = scopeKey(scope)
|
||||
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
|
||||
const existing = cache.get(key)
|
||||
if (existing) {
|
||||
cache.delete(key)
|
||||
|
|
@ -118,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||
|
||||
const entry = createRoot(
|
||||
(dispose) => ({
|
||||
value: createPromptSession(serverSDK().scope, scope),
|
||||
value: createPromptSession(target?.scope ?? serverSDK().scope, scope),
|
||||
dispose,
|
||||
}),
|
||||
owner,
|
||||
|
|
@ -130,7 +133,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||
}
|
||||
|
||||
const session = createMemo(() => load(scope()))
|
||||
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
||||
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||
scope ? load(scope, target) : session()
|
||||
const ready = createPromptReady(session)
|
||||
|
||||
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||
|
|
@ -146,7 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||
|
||||
return {
|
||||
ready,
|
||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
||||
capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||
pick(scope, target).capture(),
|
||||
current: withSuspense(() => session().current()),
|
||||
cursor: withSuspense(() => session().cursor()),
|
||||
dirty: withSuspense(() => session().dirty()),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,99 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import {
|
||||
adaptServerEvent,
|
||||
applyWorkspaceOperationEvent,
|
||||
applyWorktreeEvent,
|
||||
coalesceServerEvents,
|
||||
enqueueServerEvent,
|
||||
resumeStreamAfterPageShow,
|
||||
} from "./server-sdk"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { Worktree } from "@/utils/worktree"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
|
||||
describe("applyWorktreeEvent", () => {
|
||||
test("resolves readiness in the server scope that received the event", () => {
|
||||
const directory = "/repo/worktree-ready"
|
||||
Worktree.pending(ServerScope.local, directory)
|
||||
|
||||
expect(
|
||||
applyWorktreeEvent(
|
||||
ServerScope.local,
|
||||
{ directory, payload: { id: "ready", type: "worktree.ready", properties: { name: "ready" } } as Event },
|
||||
"failed",
|
||||
),
|
||||
).toBe(true)
|
||||
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "ready" })
|
||||
})
|
||||
|
||||
test("preserves the server failure message", () => {
|
||||
const directory = "/repo/worktree-failed"
|
||||
Worktree.pending(ServerScope.local, directory)
|
||||
|
||||
applyWorktreeEvent(
|
||||
ServerScope.local,
|
||||
{
|
||||
directory,
|
||||
payload: {
|
||||
id: "failed",
|
||||
type: "worktree.failed",
|
||||
properties: { name: "failed", message: "bootstrap failed" },
|
||||
} as Event,
|
||||
},
|
||||
"fallback",
|
||||
)
|
||||
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "failed", message: "bootstrap failed" })
|
||||
})
|
||||
|
||||
test("does not let duplicate readiness settle placement after creation", () => {
|
||||
const directory = "/repo/worktree-placement"
|
||||
Worktree.ready(ServerScope.local, directory)
|
||||
WorkspaceOperation.start(ServerScope.local, "session-worktree-placement", "create", directory)
|
||||
|
||||
applyWorktreeEvent(
|
||||
ServerScope.local,
|
||||
{ directory, payload: { id: "ready-again", type: "worktree.ready", properties: { name: "ready" } } as Event },
|
||||
"failed",
|
||||
)
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-worktree-placement")?.status).toBe("pending")
|
||||
})
|
||||
})
|
||||
|
||||
test("moved events complete the matching workspace operation", () => {
|
||||
WorkspaceOperation.start(ServerScope.local, "session-move", "move", "/workspace")
|
||||
applyWorkspaceOperationEvent(ServerScope.local, {
|
||||
directory: "/workspace",
|
||||
payload: {
|
||||
id: "moved",
|
||||
type: "session.next.moved",
|
||||
properties: {
|
||||
timestamp: Date.now(),
|
||||
sessionID: "session-move",
|
||||
location: { directory: "/workspace" },
|
||||
},
|
||||
} as Event,
|
||||
})
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-move")?.status).toBe("complete")
|
||||
})
|
||||
|
||||
test("current moved events complete the matching workspace operation", () => {
|
||||
WorkspaceOperation.start(ServerScope.local, "session-current-move", "move", "/workspace")
|
||||
applyWorkspaceOperationEvent(ServerScope.local, {
|
||||
directory: "/workspace",
|
||||
payload: adaptServerEvent({
|
||||
id: "moved-current",
|
||||
created: Date.now(),
|
||||
type: "session.moved",
|
||||
data: {
|
||||
sessionID: "session-current-move",
|
||||
location: { directory: "/workspace" },
|
||||
},
|
||||
} as OpenCodeEvent),
|
||||
})
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session-current-move")?.status).toBe("complete")
|
||||
})
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
test("restarts a stream only after a back-forward cache restore", () => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { useGlobal } from "./global"
|
|||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
||||
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
|
||||
import { Worktree } from "@/utils/worktree"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
|
|
@ -138,6 +140,34 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
|||
return output
|
||||
}
|
||||
|
||||
export function applyWorktreeEvent(scope: ServerScope, event: QueuedServerEvent, fallback: string) {
|
||||
if (event.payload.type === "worktree.ready") {
|
||||
const pending = Worktree.get(scope, event.directory)?.status === "pending"
|
||||
Worktree.ready(scope, event.directory)
|
||||
if (pending) WorkspaceOperation.completeCreate(scope, event.directory)
|
||||
return true
|
||||
}
|
||||
if (event.payload.type !== "worktree.failed") return false
|
||||
const message = event.payload.properties.message ?? fallback
|
||||
Worktree.failed(scope, event.directory, message)
|
||||
WorkspaceOperation.failCreate(scope, event.directory, message)
|
||||
return true
|
||||
}
|
||||
|
||||
export function applyWorkspaceOperationEvent(scope: ServerScope, event: QueuedServerEvent) {
|
||||
if (event.payload.current?.type === "session.moved") {
|
||||
WorkspaceOperation.complete(
|
||||
scope,
|
||||
event.payload.current.data.sessionID,
|
||||
event.payload.current.data.location.directory,
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (event.payload.type !== "session.next.moved") return false
|
||||
WorkspaceOperation.complete(scope, event.payload.properties.sessionID, event.payload.properties.location.directory)
|
||||
return true
|
||||
}
|
||||
|
||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||
if (
|
||||
event?.type === "session.text.delta" ||
|
||||
|
|
@ -186,6 +216,7 @@ type ServerSDKBase = {
|
|||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const abort = new AbortController()
|
||||
|
||||
const eventFetch = (() => {
|
||||
|
|
@ -238,7 +269,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||
last = Date.now()
|
||||
const output = coalesceServerEvents(events)
|
||||
batch(() => {
|
||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||
output.forEach((event) => {
|
||||
applyWorktreeEvent(scope, event, language.t("common.requestFailed"))
|
||||
applyWorkspaceOperationEvent(scope, event)
|
||||
emitter.emit(event.directory, event.payload)
|
||||
})
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
|
|
@ -433,6 +468,14 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
|||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||
directory,
|
||||
}),
|
||||
createApi(next: string) {
|
||||
return createCompatibleApi({
|
||||
protocol: serverSDK.protocol,
|
||||
current: serverSDK.currentApi,
|
||||
legacy: (target) => serverSDK.createClient({ directory: target ?? next, throwOnError: true }),
|
||||
directory: next,
|
||||
})
|
||||
},
|
||||
event: emitter,
|
||||
get url() {
|
||||
return serverSDK.url
|
||||
|
|
|
|||
|
|
@ -220,6 +220,47 @@ describe("server session", () => {
|
|||
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
||||
})
|
||||
|
||||
test("applies moved session locations without evicting cached state", () => {
|
||||
const current = { ...session("child"), directory: "/repo/worktree", path: undefined }
|
||||
const ctx = setup({ child: current })
|
||||
ctx.store.remember(current)
|
||||
|
||||
ctx.store.apply({
|
||||
type: "session.next.moved",
|
||||
properties: {
|
||||
sessionID: "child",
|
||||
location: { directory: "/repo" },
|
||||
subdirectory: "packages/app",
|
||||
},
|
||||
})
|
||||
|
||||
expect(ctx.store.get("child")).toMatchObject({ directory: "/repo", path: "packages/app" })
|
||||
})
|
||||
|
||||
test("does not reuse a hung signal-scoped placement refresh", async () => {
|
||||
let calls = 0
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => {
|
||||
calls++
|
||||
if (calls === 1) return new Promise<never>(() => {})
|
||||
return { data: { ...session("child"), directory: "/destination" } }
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const store = createServerSession(client)
|
||||
store.remember({ ...session("child"), directory: "/source" })
|
||||
const first = new AbortController()
|
||||
|
||||
void store.resolve("child", { force: true, signal: first.signal })
|
||||
first.abort()
|
||||
const result = await store.resolve("child", { force: true, signal: new AbortController().signal })
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(result.directory).toBe("/destination")
|
||||
expect(store.get("child")?.directory).toBe("/destination")
|
||||
})
|
||||
|
||||
test("loads session content through the server client", async () => {
|
||||
const ctx = setup({ root: session("root") })
|
||||
|
||||
|
|
@ -340,6 +381,17 @@ describe("server session", () => {
|
|||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
})
|
||||
|
||||
test("indexes optimistic users for the current timeline projection", () => {
|
||||
const ctx = setup({})
|
||||
const message = userMessage("message-optimistic")
|
||||
|
||||
ctx.store.optimistic.add({ sessionID: "child", message, parts: [] })
|
||||
|
||||
expect(ctx.store.data.session_message.child.map((item) => item.id)).toEqual([message.id])
|
||||
ctx.store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
expect(ctx.store.data.session_message.child).toEqual([])
|
||||
})
|
||||
|
||||
test("backfills an assistant-only initial page through its user root", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
|
|
|
|||
|
|
@ -298,22 +298,24 @@ export function createServerSession(
|
|||
return session
|
||||
}
|
||||
|
||||
const resolve = (sessionID: string, options?: { force?: boolean }) => {
|
||||
const resolve = (sessionID: string, options?: { force?: boolean; signal?: AbortSignal }) => {
|
||||
const cached = data.info[sessionID]
|
||||
if (cached && !options?.force) return Promise.resolve(cached)
|
||||
const pending = requests.get(sessionID)
|
||||
const pending = options?.signal ? undefined : requests.get(sessionID)
|
||||
if (pending) return pending
|
||||
const active = generation(sessionID)
|
||||
const request = sessionApi
|
||||
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
|
||||
: client.session.get({ sessionID }).then((result) => {
|
||||
? sessionApi.get({ sessionID }, { signal: options?.signal }).then(normalizeSessionInfo)
|
||||
: client.session.get({ sessionID }, { signal: options?.signal }).then((result) => {
|
||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||
return result.data
|
||||
})
|
||||
const resolved = request.then((result) => {
|
||||
if (options?.signal?.aborted) return result
|
||||
if (generations.get(sessionID) !== active) return result
|
||||
return remember(result)
|
||||
})
|
||||
if (options?.signal) return resolved
|
||||
requests.set(sessionID, resolved)
|
||||
const cleanup = () => {
|
||||
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
||||
|
|
@ -1015,6 +1017,27 @@ export function createServerSession(
|
|||
evict([sessionID])
|
||||
return
|
||||
}
|
||||
case "session.next.moved": {
|
||||
const props = event.properties as {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
location: { directory: string; workspaceID?: string }
|
||||
subdirectory?: string
|
||||
}
|
||||
const current = data.info[props.sessionID]
|
||||
if (!current) {
|
||||
void resolve(props.sessionID, { force: true }).catch(() => {})
|
||||
return
|
||||
}
|
||||
remember({
|
||||
...current,
|
||||
directory: props.location.directory,
|
||||
path: props.subdirectory,
|
||||
workspaceID: props.location.workspaceID,
|
||||
time: { ...current.time, updated: props.timestamp },
|
||||
})
|
||||
return
|
||||
}
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||
|
|
@ -1338,6 +1361,7 @@ export function createServerSession(
|
|||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||
if (!items)
|
||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||
indexLegacyMessage(input.message)
|
||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
|
|
@ -1371,6 +1395,9 @@ export function createServerSession(
|
|||
)
|
||||
return
|
||||
}
|
||||
setData("session_message", input.sessionID, (messages) =>
|
||||
messages?.filter((message) => message.id !== input.messageID),
|
||||
)
|
||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Event, OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
McpApi,
|
||||
McpListInput,
|
||||
|
|
@ -7,13 +7,21 @@ import type {
|
|||
SessionApi,
|
||||
SessionInfo,
|
||||
SessionListInput,
|
||||
OpenCodeEvent,
|
||||
} 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, seedActiveSessionStatuses } from "./server-sync"
|
||||
import {
|
||||
captureSessionMove,
|
||||
loadActiveSessionsQuery,
|
||||
loadMcpQuery,
|
||||
loadMcpResourcesQuery,
|
||||
seedActiveSessionStatuses,
|
||||
} from "./server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerSession } from "./server-session"
|
||||
import { adaptServerEvent } from "./server-sdk"
|
||||
|
||||
describe("MCP queries", () => {
|
||||
test("loads current servers for the requested location", async () => {
|
||||
|
|
@ -100,6 +108,51 @@ describe("active session query", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("session move normalization", () => {
|
||||
test("captures and applies current moves from the source placement", () => {
|
||||
const session = createServerSession({} as OpencodeClient)
|
||||
session.remember(sessionAt("/source"))
|
||||
const current = {
|
||||
id: "event-current-move",
|
||||
created: 10,
|
||||
type: "session.moved",
|
||||
data: { sessionID: "session", location: { directory: "/destination" } },
|
||||
} as OpenCodeEvent
|
||||
const event = adaptServerEvent(current)
|
||||
|
||||
expect(captureSessionMove(event, session.get)).toEqual({
|
||||
sessionID: "session",
|
||||
from: "/source",
|
||||
refresh: "session.next.moved",
|
||||
})
|
||||
session.applyV2(current)
|
||||
session.apply(event)
|
||||
expect(session.get("session")?.directory).toBe("/destination")
|
||||
})
|
||||
|
||||
test("captures and applies V1 moves from the source placement", () => {
|
||||
const session = createServerSession({} as OpencodeClient)
|
||||
session.remember(sessionAt("/source"))
|
||||
const event = {
|
||||
type: "session.next.moved",
|
||||
properties: {
|
||||
timestamp: 10,
|
||||
sessionID: "session",
|
||||
location: { directory: "/destination" },
|
||||
subdirectory: "packages/app",
|
||||
},
|
||||
} as Event
|
||||
|
||||
expect(captureSessionMove(event, session.get)).toEqual({
|
||||
sessionID: "session",
|
||||
from: "/source",
|
||||
refresh: "session.next.moved",
|
||||
})
|
||||
session.apply(event)
|
||||
expect(session.get("session")).toMatchObject({ directory: "/destination", path: "packages/app" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
const now = 5_000
|
||||
|
|
@ -172,6 +225,18 @@ function sessionInfo(id: string) {
|
|||
} as SessionInfo
|
||||
}
|
||||
|
||||
function sessionAt(directory: string): Session {
|
||||
return {
|
||||
id: "session",
|
||||
slug: "session",
|
||||
projectID: "project",
|
||||
directory,
|
||||
title: "Session",
|
||||
version: "",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
describe("estimateRootSessionTotal", () => {
|
||||
test("keeps exact total for full fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack
|
|||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { ServerSDK } from "./server-sdk"
|
||||
import { ServerSDK, type ServerEvent } from "./server-sdk"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
bootstrapGlobal,
|
||||
|
|
@ -46,6 +46,17 @@ import { ServerConnection, useServer } from "./server"
|
|||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
||||
|
||||
export function captureSessionMove(event: ServerEvent, get: (sessionID: string) => { directory: string } | undefined) {
|
||||
const sessionID =
|
||||
event.current?.type === "session.moved"
|
||||
? event.current.data.sessionID
|
||||
: event.type === "session.next.moved"
|
||||
? event.properties.sessionID
|
||||
: undefined
|
||||
if (!sessionID) return
|
||||
return { sessionID, from: get(sessionID)?.directory, refresh: "session.next.moved" as const }
|
||||
}
|
||||
import { persisted } from "@/utils/persist"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import type {
|
||||
|
|
@ -523,19 +534,49 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||
})
|
||||
}
|
||||
|
||||
const reindexSession = (sessionID: string, from?: string) => {
|
||||
const next = session.get(sessionID)
|
||||
if (!next) return
|
||||
indexSession(next)
|
||||
if (!from) return
|
||||
const source = children.children[directoryKey(from)]
|
||||
if (!source) return
|
||||
applyDirectoryEvent({
|
||||
event: {
|
||||
type: "session.moved",
|
||||
properties: {
|
||||
sessionID,
|
||||
projectID: next.projectID,
|
||||
location: { directory: next.directory, workspaceID: next.workspaceID },
|
||||
subpath: next.path,
|
||||
},
|
||||
},
|
||||
directory: from,
|
||||
store: source[0],
|
||||
setStore: source[1],
|
||||
push: queue.push,
|
||||
retainedLimit: sessionMeta.get(directoryKey(from))?.limit,
|
||||
sessionContent: false,
|
||||
permission: session.data.permission,
|
||||
loadLsp() {},
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
const eventType: string = event.type
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
const moved = captureSessionMove(event, session.get)
|
||||
|
||||
if (event.current) session.applyV2(event.current)
|
||||
session.apply(event)
|
||||
if (moved) reindexSession(moved.sessionID, moved.from)
|
||||
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
||||
homeSessions.apply(event)
|
||||
}
|
||||
homeSessions.refresh(event.type)
|
||||
homeSessions.refresh(moved?.refresh ?? event.type)
|
||||
|
||||
if (directory === "global") {
|
||||
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
|
||||
|
|
@ -566,10 +607,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||
return
|
||||
}
|
||||
|
||||
if (event.current?.type === "session.moved") {
|
||||
const info = session.get(event.current.data.sessionID)
|
||||
if (info) indexSession(info)
|
||||
}
|
||||
if (event.current?.type === "session.forked")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
|
|
@ -681,6 +718,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
project: projectApi,
|
||||
session,
|
||||
reindexSession,
|
||||
homeSessions,
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
|
|
|
|||
|
|
@ -894,6 +894,12 @@ export const dict = {
|
|||
"session.delete.confirm": 'حذف الجلسة "{{name}}"؟',
|
||||
"session.delete.button": "حذف الجلسة",
|
||||
"workspace.new": "مساحة عمل جديدة",
|
||||
"workspace.move.title": "نقل إلى مساحة العمل",
|
||||
"workspace.move.failed": "تعذّر نقل الجلسة",
|
||||
"workspace.lifecycle.creating": "جارٍ إنشاء مساحة العمل",
|
||||
"workspace.lifecycle.created": "تم إنشاء مساحة العمل",
|
||||
"workspace.lifecycle.moving": "جارٍ النقل إلى مساحة العمل",
|
||||
"workspace.lifecycle.set": "تم تعيين مساحة العمل",
|
||||
"workspace.type.local": "محلي",
|
||||
"workspace.type.sandbox": "صندوق رمل",
|
||||
"workspace.create.failed.title": "فشل إنشاء مساحة العمل",
|
||||
|
|
|
|||
|
|
@ -908,6 +908,12 @@ export const dict = {
|
|||
"session.delete.confirm": 'Excluir sessão "{{name}}"?',
|
||||
"session.delete.button": "Excluir sessão",
|
||||
"workspace.new": "Novo espaço de trabalho",
|
||||
"workspace.move.title": "Mover para o espaço de trabalho",
|
||||
"workspace.move.failed": "Não foi possível mover a sessão",
|
||||
"workspace.lifecycle.creating": "Criando espaço de trabalho",
|
||||
"workspace.lifecycle.created": "Espaço de trabalho criado",
|
||||
"workspace.lifecycle.moving": "Movendo para o espaço de trabalho",
|
||||
"workspace.lifecycle.set": "Espaço de trabalho definido",
|
||||
"workspace.type.local": "local",
|
||||
"workspace.type.sandbox": "sandbox",
|
||||
"workspace.create.failed.title": "Falha ao criar espaço de trabalho",
|
||||
|
|
|
|||
|
|
@ -984,6 +984,12 @@ export const dict = {
|
|||
"session.delete.button": "Izbriši sesiju",
|
||||
|
||||
"workspace.new": "Novi radni prostor",
|
||||
"workspace.move.title": "Premjesti u radni prostor",
|
||||
"workspace.move.failed": "Premještanje sesije nije uspjelo",
|
||||
"workspace.lifecycle.creating": "Kreiranje radnog prostora",
|
||||
"workspace.lifecycle.created": "Radni prostor je kreiran",
|
||||
"workspace.lifecycle.moving": "Premještanje u radni prostor",
|
||||
"workspace.lifecycle.set": "Radni prostor je postavljen",
|
||||
"workspace.type.local": "lokalno",
|
||||
"workspace.type.sandbox": "sandbox",
|
||||
"workspace.create.failed.title": "Neuspješno kreiranje radnog prostora",
|
||||
|
|
|
|||
|
|
@ -976,6 +976,12 @@ export const dict = {
|
|||
"session.delete.button": "Slet session",
|
||||
|
||||
"workspace.new": "Nyt arbejdsområde",
|
||||
"workspace.move.title": "Flyt til arbejdsområde",
|
||||
"workspace.move.failed": "Sessionen kunne ikke flyttes",
|
||||
"workspace.lifecycle.creating": "Opretter arbejdsområde",
|
||||
"workspace.lifecycle.created": "Arbejdsområde oprettet",
|
||||
"workspace.lifecycle.moving": "Flytter til arbejdsområde",
|
||||
"workspace.lifecycle.set": "Arbejdsområde angivet",
|
||||
"workspace.type.local": "lokal",
|
||||
"workspace.type.sandbox": "sandkasse",
|
||||
"workspace.create.failed.title": "Kunne ikke oprette arbejdsområde",
|
||||
|
|
|
|||
|
|
@ -920,6 +920,12 @@ export const dict = {
|
|||
"session.delete.confirm": 'Sitzung "{{name}}" löschen?',
|
||||
"session.delete.button": "Sitzung löschen",
|
||||
"workspace.new": "Neuer Arbeitsbereich",
|
||||
"workspace.move.title": "In Arbeitsbereich verschieben",
|
||||
"workspace.move.failed": "Sitzung konnte nicht verschoben werden",
|
||||
"workspace.lifecycle.creating": "Arbeitsbereich wird erstellt",
|
||||
"workspace.lifecycle.created": "Arbeitsbereich erstellt",
|
||||
"workspace.lifecycle.moving": "Wird in den Arbeitsbereich verschoben",
|
||||
"workspace.lifecycle.set": "Arbeitsbereich festgelegt",
|
||||
"workspace.type.local": "lokal",
|
||||
"workspace.type.sandbox": "Sandbox",
|
||||
"workspace.create.failed.title": "Arbeitsbereich konnte nicht erstellt werden",
|
||||
|
|
|
|||
|
|
@ -1075,6 +1075,12 @@ export const dict = {
|
|||
"session.delete.button": "Delete session",
|
||||
|
||||
"workspace.new": "New workspace",
|
||||
"workspace.move.title": "Move to workspace",
|
||||
"workspace.move.failed": "Failed to move session",
|
||||
"workspace.lifecycle.creating": "Creating workspace",
|
||||
"workspace.lifecycle.created": "Workspace created",
|
||||
"workspace.lifecycle.moving": "Moving to workspace",
|
||||
"workspace.lifecycle.set": "Workspace set",
|
||||
"workspace.type.local": "local",
|
||||
"workspace.type.sandbox": "sandbox",
|
||||
"workspace.create.failed.title": "Failed to create workspace",
|
||||
|
|
|
|||
|
|
@ -992,6 +992,12 @@ export const dict = {
|
|||
"session.delete.button": "Eliminar sesión",
|
||||
|
||||
"workspace.new": "Nuevo espacio de trabajo",
|
||||
"workspace.move.title": "Mover al espacio de trabajo",
|
||||
"workspace.move.failed": "No se pudo mover la sesión",
|
||||
"workspace.lifecycle.creating": "Creando espacio de trabajo",
|
||||
"workspace.lifecycle.created": "Espacio de trabajo creado",
|
||||
"workspace.lifecycle.moving": "Moviendo al espacio de trabajo",
|
||||
"workspace.lifecycle.set": "Espacio de trabajo establecido",
|
||||
"workspace.type.local": "local",
|
||||
"workspace.type.sandbox": "sandbox",
|
||||
"workspace.create.failed.title": "Fallo al crear espacio de trabajo",
|
||||
|
|
|
|||
|
|
@ -919,6 +919,12 @@ export const dict = {
|
|||
"session.delete.confirm": 'Supprimer la session "{{name}}" ?',
|
||||
"session.delete.button": "Supprimer la session",
|
||||
"workspace.new": "Nouvel espace de travail",
|
||||
"workspace.move.title": "Déplacer vers l’espace de travail",
|
||||
"workspace.move.failed": "Impossible de déplacer la session",
|
||||
"workspace.lifecycle.creating": "Création de l’espace de travail",
|
||||
"workspace.lifecycle.created": "Espace de travail créé",
|
||||
"workspace.lifecycle.moving": "Déplacement vers l’espace de travail",
|
||||
"workspace.lifecycle.set": "Espace de travail défini",
|
||||
"workspace.type.local": "local",
|
||||
"workspace.type.sandbox": "bac à sable",
|
||||
"workspace.create.failed.title": "Échec de la création de l'espace de travail",
|
||||
|
|
|
|||
|
|
@ -901,6 +901,12 @@ export const dict = {
|
|||
"session.delete.confirm": 'セッション "{{name}}" を削除しますか?',
|
||||
"session.delete.button": "セッションを削除",
|
||||
"workspace.new": "新しいワークスペース",
|
||||
"workspace.move.title": "ワークスペースに移動",
|
||||
"workspace.move.failed": "セッションを移動できませんでした",
|
||||
"workspace.lifecycle.creating": "ワークスペースを作成中",
|
||||
"workspace.lifecycle.created": "ワークスペースを作成しました",
|
||||
"workspace.lifecycle.moving": "ワークスペースに移動中",
|
||||
"workspace.lifecycle.set": "ワークスペースを設定しました",
|
||||
"workspace.type.local": "ローカル",
|
||||
"workspace.type.sandbox": "サンドボックス",
|
||||
"workspace.create.failed.title": "ワークスペースの作成に失敗しました",
|
||||
|
|
|
|||
|
|
@ -742,6 +742,12 @@ export const dict = {
|
|||
"session.delete.confirm": '"{{name}}" 세션을 삭제하시겠습니까?',
|
||||
"session.delete.button": "세션 삭제",
|
||||
"workspace.new": "새 작업 공간",
|
||||
"workspace.move.title": "작업 공간으로 이동",
|
||||
"workspace.move.failed": "세션을 이동하지 못했습니다",
|
||||
"workspace.lifecycle.creating": "작업 공간 생성 중",
|
||||
"workspace.lifecycle.created": "작업 공간을 만들었습니다",
|
||||
"workspace.lifecycle.moving": "작업 공간으로 이동 중",
|
||||
"workspace.lifecycle.set": "작업 공간을 설정했습니다",
|
||||
"workspace.type.local": "로컬",
|
||||
"workspace.type.sandbox": "샌드박스",
|
||||
"workspace.create.failed.title": "작업 공간 생성 실패",
|
||||
|
|
|
|||
|
|
@ -829,6 +829,12 @@ export const dict = {
|
|||
"session.delete.button": "Slett sesjon",
|
||||
|
||||
"workspace.new": "Nytt arbeidsområde",
|
||||
"workspace.move.title": "Flytt til arbeidsområde",
|
||||
"workspace.move.failed": "Kunne ikke flytte økten",
|
||||
"workspace.lifecycle.creating": "Oppretter arbeidsområde",
|
||||
"workspace.lifecycle.created": "Arbeidsområdet er opprettet",
|
||||
"workspace.lifecycle.moving": "Flytter til arbeidsområde",
|
||||
"workspace.lifecycle.set": "Arbeidsområdet er angitt",
|
||||
"workspace.type.local": "lokal",
|
||||
"workspace.type.sandbox": "sandkasse",
|
||||
"workspace.create.failed.title": "Kunne ikke opprette arbeidsområde",
|
||||
|
|
|
|||
|
|
@ -907,6 +907,12 @@ export const dict = {
|
|||
"session.delete.confirm": 'Usunąć sesję "{{name}}"?',
|
||||
"session.delete.button": "Usuń sesję",
|
||||
"workspace.new": "Nowa przestrzeń robocza",
|
||||
"workspace.move.title": "Przenieś do obszaru roboczego",
|
||||
"workspace.move.failed": "Nie udało się przenieść sesji",
|
||||
"workspace.lifecycle.creating": "Tworzenie obszaru roboczego",
|
||||
"workspace.lifecycle.created": "Utworzono obszar roboczy",
|
||||
"workspace.lifecycle.moving": "Przenoszenie do obszaru roboczego",
|
||||
"workspace.lifecycle.set": "Ustawiono obszar roboczy",
|
||||
"workspace.type.local": "lokalna",
|
||||
"workspace.type.sandbox": "piaskownica",
|
||||
"workspace.create.failed.title": "Nie udało się utworzyć przestrzeni roboczej",
|
||||
|
|
|
|||
|
|
@ -987,6 +987,12 @@ export const dict = {
|
|||
"session.delete.button": "Удалить сессию",
|
||||
|
||||
"workspace.new": "Новое рабочее пространство",
|
||||
"workspace.move.title": "Переместить в рабочую область",
|
||||
"workspace.move.failed": "Не удалось переместить сеанс",
|
||||
"workspace.lifecycle.creating": "Создание рабочей области",
|
||||
"workspace.lifecycle.created": "Рабочая область создана",
|
||||
"workspace.lifecycle.moving": "Перемещение в рабочую область",
|
||||
"workspace.lifecycle.set": "Рабочая область задана",
|
||||
"workspace.type.local": "локальное",
|
||||
"workspace.type.sandbox": "песочница",
|
||||
"workspace.create.failed.title": "Не удалось создать рабочее пространство",
|
||||
|
|
|
|||
|
|
@ -971,6 +971,12 @@ export const dict = {
|
|||
"session.delete.button": "ลบเซสชัน",
|
||||
|
||||
"workspace.new": "พื้นที่ทำงานใหม่",
|
||||
"workspace.move.title": "ย้ายไปยังพื้นที่ทำงาน",
|
||||
"workspace.move.failed": "ย้ายเซสชันไม่สำเร็จ",
|
||||
"workspace.lifecycle.creating": "กำลังสร้างพื้นที่ทำงาน",
|
||||
"workspace.lifecycle.created": "สร้างพื้นที่ทำงานแล้ว",
|
||||
"workspace.lifecycle.moving": "กำลังย้ายไปยังพื้นที่ทำงาน",
|
||||
"workspace.lifecycle.set": "ตั้งค่าพื้นที่ทำงานแล้ว",
|
||||
"workspace.type.local": "ในเครื่อง",
|
||||
"workspace.type.sandbox": "แซนด์บ็อกซ์",
|
||||
"workspace.create.failed.title": "ไม่สามารถสร้างพื้นที่ทำงาน",
|
||||
|
|
|
|||
|
|
@ -991,6 +991,12 @@ export const dict = {
|
|||
"session.delete.button": "Oturumu sil",
|
||||
|
||||
"workspace.new": "Yeni çalışma alanı",
|
||||
"workspace.move.title": "Çalışma alanına taşı",
|
||||
"workspace.move.failed": "Oturum taşınamadı",
|
||||
"workspace.lifecycle.creating": "Çalışma alanı oluşturuluyor",
|
||||
"workspace.lifecycle.created": "Çalışma alanı oluşturuldu",
|
||||
"workspace.lifecycle.moving": "Çalışma alanına taşınıyor",
|
||||
"workspace.lifecycle.set": "Çalışma alanı ayarlandı",
|
||||
"workspace.type.local": "yerel",
|
||||
"workspace.type.sandbox": "sandbox",
|
||||
"workspace.create.failed.title": "Çalışma alanı oluşturulamadı",
|
||||
|
|
|
|||
|
|
@ -1078,6 +1078,12 @@ export const dict = {
|
|||
"session.delete.button": "Видалити сесію",
|
||||
|
||||
"workspace.new": "Нова робоча область",
|
||||
"workspace.move.title": "Перемістити до робочого простору",
|
||||
"workspace.move.failed": "Не вдалося перемістити сеанс",
|
||||
"workspace.lifecycle.creating": "Створення робочого простору",
|
||||
"workspace.lifecycle.created": "Робочий простір створено",
|
||||
"workspace.lifecycle.moving": "Переміщення до робочого простору",
|
||||
"workspace.lifecycle.set": "Робочий простір установлено",
|
||||
"workspace.type.local": "локальна",
|
||||
"workspace.type.sandbox": "пісочниця",
|
||||
"workspace.create.failed.title": "Не вдалося створити робочу область",
|
||||
|
|
|
|||
|
|
@ -964,6 +964,12 @@ export const dict = {
|
|||
"session.delete.button": "删除会话",
|
||||
|
||||
"workspace.new": "新建工作区",
|
||||
"workspace.move.title": "移动到工作区",
|
||||
"workspace.move.failed": "无法移动会话",
|
||||
"workspace.lifecycle.creating": "正在创建工作区",
|
||||
"workspace.lifecycle.created": "工作区已创建",
|
||||
"workspace.lifecycle.moving": "正在移动到工作区",
|
||||
"workspace.lifecycle.set": "工作区已设置",
|
||||
"workspace.type.local": "本地",
|
||||
"workspace.type.sandbox": "沙盒",
|
||||
"workspace.create.failed.title": "创建工作区失败",
|
||||
|
|
|
|||
|
|
@ -960,6 +960,12 @@ export const dict = {
|
|||
"session.delete.button": "刪除工作階段",
|
||||
|
||||
"workspace.new": "新增工作區",
|
||||
"workspace.move.title": "移動到工作區",
|
||||
"workspace.move.failed": "無法移動工作階段",
|
||||
"workspace.lifecycle.creating": "正在建立工作區",
|
||||
"workspace.lifecycle.created": "工作區已建立",
|
||||
"workspace.lifecycle.moving": "正在移動到工作區",
|
||||
"workspace.lifecycle.set": "工作區已設定",
|
||||
"workspace.type.local": "本地",
|
||||
"workspace.type.sandbox": "沙盒",
|
||||
"workspace.create.failed.title": "建立工作區失敗",
|
||||
|
|
|
|||
|
|
@ -327,4 +327,9 @@
|
|||
animation-range: 0 0.1px;
|
||||
}
|
||||
}
|
||||
|
||||
body[data-new-layout] [data-slot="session-turn-diffs-header"] {
|
||||
height: 24px;
|
||||
padding-block: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { displayName, errorMessage, projectForSession } from "@/pages/layout/hel
|
|||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { archiveHomeSession } from "../home-session-archive"
|
||||
import type { HomeController } from "./home-controller"
|
||||
|
|
@ -210,6 +211,7 @@ export function createHomeSessionsController(home: HomeController) {
|
|||
const conn = home.server.focused()
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!conn || !ctx) return
|
||||
if (WorkspaceOperation.get(ctx.sdk.scope, session.id)?.status === "pending") return
|
||||
const [, setStore] = ctx.sync.child(session.directory)
|
||||
await archiveHomeSession({
|
||||
server: ServerConnection.key(conn),
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import { useDirectoryPicker } from "@/components/directory-picker"
|
|||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useLanguage, type Locale } from "@/context/language"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import {
|
||||
displayName,
|
||||
effectiveWorkspaceOrder,
|
||||
|
|
@ -872,6 +873,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
if (WorkspaceOperation.get(serverSDK().scope, session.id)?.status === "pending") return
|
||||
const [store, setStore] = serverSync().child(session.directory)
|
||||
const sessions = store.session ?? []
|
||||
const index = sessions.findIndex((s) => s.id === session.id)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { Show, createEffect, createMemo, createResource, createSignal, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
|
|
@ -34,10 +34,10 @@ import { Persist, persisted } from "@/utils/persist"
|
|||
import createPresence from "solid-presence"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
||||
import { useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { isWorkspaceSelection } from "@/utils/workspace"
|
||||
|
||||
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||
const providerTipExitDuration = 250
|
||||
|
||||
/**
|
||||
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
||||
|
|
@ -63,6 +63,7 @@ export default function NewSessionPage() {
|
|||
useSettingsCommand()
|
||||
const route = useSessionKey()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const tabs = useTabs()
|
||||
const local = useLocal()
|
||||
const model = createPromptModelSelection({ agent: local.agent.current })
|
||||
|
||||
|
|
@ -76,13 +77,26 @@ export default function NewSessionPage() {
|
|||
})
|
||||
const projectControls = createPromptProjectControls()
|
||||
|
||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||
const rightMount = useTitlebarRightMount()
|
||||
|
||||
const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
||||
const draft = createMemo(() =>
|
||||
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === searchParams.draftId),
|
||||
)
|
||||
const setWorktree = (worktree: string | undefined) => {
|
||||
const id = searchParams.draftId
|
||||
if (!id) return
|
||||
tabs.updateDraft(id, { worktree })
|
||||
}
|
||||
const showWorkspaceBar = createMemo(() => sync().project?.vcs === "git")
|
||||
const selectedWorktree = createMemo(() => {
|
||||
const project = sync().project
|
||||
const worktree = draft()?.worktree
|
||||
if (!project || !worktree) return undefined
|
||||
return isWorkspaceSelection(project, worktree) ? worktree : undefined
|
||||
})
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (!showWorkspaceBar()) return "main"
|
||||
if (store.worktree) return store.worktree
|
||||
const worktree = selectedWorktree()
|
||||
if (worktree) return worktree
|
||||
const project = sync().project
|
||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||
return "main"
|
||||
|
|
@ -92,8 +106,12 @@ export default function NewSessionPage() {
|
|||
const selectedBranch = createMemo(() => {
|
||||
const worktree = newSessionWorktree()
|
||||
if (worktree === "main" || worktree === "create") return localBranch()
|
||||
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
|
||||
return serverSync().child(worktree)[0].vcs?.branch
|
||||
})
|
||||
const selectWorktree = (worktree: string) => {
|
||||
const project = sync().project
|
||||
setWorktree(worktree === "main" && project?.worktree !== sdk().directory ? project?.worktree : worktree)
|
||||
}
|
||||
const promptInputV2Controller = usePromptInputV2Controller({
|
||||
get controls() {
|
||||
return inputController()
|
||||
|
|
@ -101,8 +119,8 @@ export default function NewSessionPage() {
|
|||
get newSessionWorktree() {
|
||||
return newSessionWorktree()
|
||||
},
|
||||
onNewSessionWorktreeReset: () => setStore("worktree", undefined),
|
||||
onSubmit: () => comments.clear(),
|
||||
onNewSessionWorktreeReset: () => setWorktree(undefined),
|
||||
onSubmit: comments.clear,
|
||||
})
|
||||
const projectController = createPromptProjectController({
|
||||
controls: projectControls,
|
||||
|
|
@ -185,14 +203,7 @@ export default function NewSessionPage() {
|
|||
projectRoot={projectRoot()}
|
||||
workspaces={sync().project?.sandboxes ?? []}
|
||||
branch={selectedBranch()}
|
||||
onChange={(value) =>
|
||||
setStore(
|
||||
"worktree",
|
||||
value === "main" && sync().project?.worktree !== sdk().directory
|
||||
? sync().project?.worktree
|
||||
: value,
|
||||
)
|
||||
}
|
||||
onChange={selectWorktree}
|
||||
onDone={promptInputV2Controller.restoreFocus}
|
||||
/>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ 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 { canMoveSessionToWorkspace, WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
import { createSessionOwnership } from "./session/session-ownership"
|
||||
import { createSessionLineage } from "./session/session-lineage"
|
||||
|
|
@ -599,6 +600,7 @@ export default function Page() {
|
|||
const [store, setStore] = createStore({
|
||||
...sessionViewState(),
|
||||
newSessionWorktree: "main",
|
||||
sessionDetailsOpen: false,
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
|
|
@ -698,6 +700,19 @@ export default function Page() {
|
|||
: skipToken,
|
||||
}
|
||||
})
|
||||
const sessionDetailsQuery = createQuery(() => ({
|
||||
queryKey: [...vcsKey(), "git"] as const,
|
||||
enabled: store.sessionDetailsOpen && sync().project?.vcs === "git",
|
||||
queryFn: () =>
|
||||
sdk()
|
||||
.api.vcs.diff({ location: { directory: sdk().directory }, mode: "working" })
|
||||
.then((result) => result.data)
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load session details diff", { error })
|
||||
return []
|
||||
}),
|
||||
}))
|
||||
const sessionDetailsDiffs = () => (sessionDetailsQuery.isFetched ? (sessionDetailsQuery.data ?? []) : [])
|
||||
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
|
||||
const reviewDiffs = () => {
|
||||
if (reviewMode() === "git" || reviewMode() === "branch")
|
||||
|
|
@ -1700,6 +1715,8 @@ export default function Page() {
|
|||
}
|
||||
|
||||
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
||||
const workspaceOperationPending = (sessionID: string) =>
|
||||
WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending"
|
||||
|
||||
const queuedFollowups = createMemo(() => {
|
||||
const id = params.id
|
||||
|
|
@ -1713,8 +1730,20 @@ export default function Page() {
|
|||
return followup.edit[id]
|
||||
})
|
||||
|
||||
const workspaceMoveEligible = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return canMoveSessionToWorkspace({
|
||||
queued: followup.items[id]?.length ?? 0,
|
||||
failed: !!followup.failed[id],
|
||||
paused: !!followup.paused[id],
|
||||
editing: !!followup.edit[id],
|
||||
})
|
||||
})
|
||||
|
||||
const followupMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
||||
if (workspaceOperationPending(input.sessionID)) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
||||
if (!item) return
|
||||
|
|
@ -1724,6 +1753,7 @@ export default function Page() {
|
|||
|
||||
const ok = await sendFollowupDraft({
|
||||
api: sdk().api.session,
|
||||
scope: serverSDK().scope,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft: item,
|
||||
|
|
@ -1786,6 +1816,7 @@ export default function Page() {
|
|||
|
||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
|
||||
if (workspaceOperationPending(sessionID)) return Promise.resolve()
|
||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||
if (!item) return Promise.resolve()
|
||||
if (followupBusy(sessionID)) return Promise.resolve()
|
||||
|
|
@ -1825,6 +1856,7 @@ export default function Page() {
|
|||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
if (workspaceOperationPending(input.sessionID)) return
|
||||
const session = sdk().api.session
|
||||
const target = sync()
|
||||
const last = target.session.get(input.sessionID)?.revert
|
||||
|
|
@ -1847,6 +1879,7 @@ export default function Page() {
|
|||
mutationFn: async (id: string) => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
|
||||
const session = sdk().api.session
|
||||
const target = sync()
|
||||
|
|
@ -1874,7 +1907,10 @@ export default function Page() {
|
|||
},
|
||||
}))
|
||||
|
||||
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
|
||||
const reverting = createMemo(() => {
|
||||
const id = params.id
|
||||
return revertMutation.isPending || restoreMutation.isPending || (!!id && workspaceOperationPending(id))
|
||||
})
|
||||
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
||||
|
||||
const revert = (input: { sessionID: string; messageID: string }) => {
|
||||
|
|
@ -1932,6 +1968,7 @@ export default function Page() {
|
|||
if (isChildSession()) return
|
||||
if (composer.blocked()) return
|
||||
if (busy(sessionID)) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
|
||||
void sendFollowup(sessionID, item.id)
|
||||
})
|
||||
|
|
@ -2102,6 +2139,9 @@ export default function Page() {
|
|||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
userMessages={visibleUserMessages()}
|
||||
diffs={sessionDetailsDiffs}
|
||||
workspaceMoveEligible={workspaceMoveEligible()}
|
||||
onSummaryOpenChange={(open) => setStore("sessionDetailsOpen", open)}
|
||||
setHistoryAnchor={(handlers) => {
|
||||
captureHistoryAnchor = handlers.capture
|
||||
restoreHistoryAnchor = handlers.restore
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export function createPromptProjectControls() {
|
|||
const target = global.ensureServerCtx(conn)
|
||||
target.projects.open(worktree)
|
||||
target.projects.touch(worktree)
|
||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree, worktree: undefined })
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
type UserActions,
|
||||
} from "@opencode-ai/session-ui/message-part"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { DiffChanges as DiffChangesV2 } from "@opencode-ai/ui/v2/diff-changes-v2"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
|
|
@ -35,6 +36,8 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
|
|
@ -49,6 +52,7 @@ import type {
|
|||
AssistantMessage,
|
||||
Message as MessageType,
|
||||
Part as PartType,
|
||||
Project,
|
||||
ToolPart,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
|
|
@ -63,6 +67,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||
import { useLanguage } from "@/context/language"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
|
|
@ -76,6 +81,12 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
|||
import { createTimelineProjection } from "./projection"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
import { isWorkspaceDirectory } from "@/utils/workspace"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
import { SessionWorkspaceMenu } from "@/components/session-workspace-menu"
|
||||
import { getProjectAvatarVariant } from "@/context/layout"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
|
|
@ -141,7 +152,7 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu
|
|||
)
|
||||
}
|
||||
|
||||
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
||||
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; action?: JSX.Element }) {
|
||||
const language = useLanguage()
|
||||
const maxFiles = 10
|
||||
const [state, setState] = createStore({
|
||||
|
|
@ -172,6 +183,7 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
|||
{showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
|
||||
</span>
|
||||
</Show>
|
||||
{props.action}
|
||||
</div>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
|
|
@ -226,6 +238,166 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
|||
)
|
||||
}
|
||||
|
||||
function WorkspaceLocationLoader() {
|
||||
const dots = ["left-0 top-0", "right-0 top-0", "left-0 bottom-0", "right-0 bottom-0"]
|
||||
return (
|
||||
<span data-component="workspace-location-loader" class="relative block size-4" aria-hidden="true">
|
||||
<span class="absolute left-[7px] top-[7px] size-0.5 bg-current" />
|
||||
<For each={dots}>
|
||||
{(position, index) => (
|
||||
<span
|
||||
class={`absolute size-1 bg-current ${position} animate-pulse`}
|
||||
style={{ "animation-delay": `${index() * -180}ms` }}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceMoveAction(props: {
|
||||
variant: "inline" | "panel"
|
||||
eligible: boolean
|
||||
sessionID: string
|
||||
project: Project
|
||||
directory: string
|
||||
messageID?: string
|
||||
dismissed: boolean
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const inline = () => props.variant === "inline"
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"group/workspace-move relative shrink-0": true,
|
||||
"ml-auto h-6 w-[155px]": inline(),
|
||||
"h-[46px] w-full bg-v2-background-bg-layer-02 rounded-b-[6px]": !inline(),
|
||||
invisible: props.dismissed,
|
||||
}}
|
||||
>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.eligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
messageID={props.messageID}
|
||||
placement={inline() ? "bottom-end" : "left-start"}
|
||||
gutter={inline() ? 4 : -2}
|
||||
class={
|
||||
inline()
|
||||
? "flex h-6 w-full items-center gap-1.5 rounded-[4px] px-1.5 pr-7 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
|
||||
: "flex h-[46px] w-full items-center gap-1.5 rounded-b-[6px] px-3 pr-9 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
|
||||
}
|
||||
>
|
||||
<IconV2 name="workspace" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
|
||||
</SessionWorkspaceMenu>
|
||||
<button
|
||||
type="button"
|
||||
class="hover-reveal absolute right-1 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100 hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onDismiss()
|
||||
}}
|
||||
>
|
||||
<IconV2 name="xmark-small" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionSummaryPanel(props: {
|
||||
project: Project
|
||||
directory: string
|
||||
local: boolean
|
||||
branch?: string
|
||||
baseBranch?: string
|
||||
diffs: { additions: number; deletions: number }[]
|
||||
sessionID: string
|
||||
moveEligible: boolean
|
||||
messageID?: string
|
||||
moveDismissed: boolean
|
||||
onMoveDismiss: () => void
|
||||
onReview: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const location = () => (props.local ? language.t("session.new.workspace.local") : getFilename(props.directory))
|
||||
const branch = () => props.branch ?? props.baseBranch
|
||||
const row =
|
||||
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="session-summary-panel"
|
||||
class="w-[280px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)]"
|
||||
>
|
||||
<div class="flex flex-col gap-1 px-0.5 py-1.5">
|
||||
<div class={row}>
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate">{displayName(props.project)}</span>
|
||||
</div>
|
||||
<div class={row}>
|
||||
<IconV2 name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 flex-1 truncate">{location()}</span>
|
||||
<IconV2 name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</div>
|
||||
<div class={row}>
|
||||
<IconV2 name="branch" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show
|
||||
when={props.branch}
|
||||
fallback={
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span>No branch</span>
|
||||
<Show when={props.baseBranch}>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<span class="truncate text-v2-text-text-faint">Based on {props.baseBranch}</span>
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="min-w-0 truncate">{branch()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none`}
|
||||
onClick={props.onReview}
|
||||
>
|
||||
<IconV2 name="review" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Show when={props.diffs.length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
|
||||
<span>
|
||||
{language.t(
|
||||
props.diffs.length === 1 ? "ui.sessionTurn.diffs.changed.one" : "ui.sessionTurn.diffs.changed.other",
|
||||
{ count: String(props.diffs.length) },
|
||||
)}
|
||||
</span>
|
||||
<span class="text-v2-text-text-muted">·</span>
|
||||
<DiffChangesV2 changes={props.diffs} />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<Show when={props.local && props.diffs.length > 0 && props.moveEligible}>
|
||||
<WorkspaceMoveAction
|
||||
variant="panel"
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
messageID={props.messageID}
|
||||
dismissed={props.moveDismissed}
|
||||
onDismiss={props.onMoveDismiss}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineDiffView(props: { diff: SummaryDiff }) {
|
||||
const fileComponent = useFileComponent()
|
||||
const view = normalize(props.diff)
|
||||
|
|
@ -253,6 +425,9 @@ export function MessageTimeline(props: {
|
|||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
userMessages: UserMessage[]
|
||||
diffs: Accessor<{ additions: number; deletions: number }[]>
|
||||
workspaceMoveEligible: boolean
|
||||
onSummaryOpenChange: (open: boolean) => void
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
|
|
@ -262,12 +437,14 @@ export function MessageTimeline(props: {
|
|||
|
||||
const navigate = useNavigate()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const { params, sessionKey } = useSessionKey()
|
||||
const ownerSessionKey = sessionKey()
|
||||
const cached = timelineCache.get(ownerSessionKey)
|
||||
|
|
@ -298,6 +475,29 @@ export function MessageTimeline(props: {
|
|||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const sessionDirectory = createMemo(() => info()?.directory ?? sdk().directory)
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
|
||||
const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false)
|
||||
const [summaryOpen, setSummaryOpen] = createSignal(false)
|
||||
const setSummary = (open: boolean) => {
|
||||
setSummaryOpen(open)
|
||||
props.onSummaryOpenChange(open)
|
||||
}
|
||||
const sessionDiffs = createMemo(props.diffs)
|
||||
createEffect(
|
||||
on(sessionID, () => {
|
||||
setSummary(false)
|
||||
setWorkspaceSuggestionDismissed(false)
|
||||
}),
|
||||
)
|
||||
const turnPadding = () => (settings.general.newLayoutDesigns() ? "px-3" : "px-4 md:px-5")
|
||||
const workspaceOperation = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
return WorkspaceOperation.get(serverSDK().scope, id)
|
||||
})
|
||||
const workspaceOperationPending = (sessionID: string) =>
|
||||
WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending"
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
|
|
@ -329,7 +529,7 @@ export function MessageTimeline(props: {
|
|||
if (value) return value
|
||||
return language.t("command.session.new")
|
||||
})
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID() || workspaceSession()))
|
||||
const projection = createTimelineProjection({
|
||||
messages: sessionMessages,
|
||||
userMessages: () => props.userMessages,
|
||||
|
|
@ -338,6 +538,19 @@ export function MessageTimeline(props: {
|
|||
status: sessionStatus,
|
||||
showReasoningSummaries: settings.general.showReasoningSummaries,
|
||||
inlineComments: settings.general.newLayoutDesigns,
|
||||
extensionRevision: workspaceOperation,
|
||||
afterTurn: (message) => {
|
||||
const operation = workspaceOperation()
|
||||
if (!operation) return []
|
||||
if (operation.messageID !== message.id && (operation.messageID || message.id !== props.userMessages.at(-1)?.id))
|
||||
return []
|
||||
return [
|
||||
new TimelineRow.WorkspaceLifecycle({
|
||||
userMessageID: message.id,
|
||||
notice: { type: "operation", operation },
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
const activeMessageID = projection.activeMessageID
|
||||
const assistantMessagesByParent = projection.assistantMessagesByParent
|
||||
|
|
@ -810,6 +1023,7 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
const archiveSession = async (sessionID: string) => {
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
const session = sync().session.get(sessionID)
|
||||
if (!session) return
|
||||
|
||||
|
|
@ -839,6 +1053,7 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
const deleteSession = async (sessionID: string) => {
|
||||
if (workspaceOperationPending(sessionID)) return false
|
||||
const session = sync().session.get(sessionID)
|
||||
if (!session) return false
|
||||
|
||||
|
|
@ -932,7 +1147,7 @@ export function MessageTimeline(props: {
|
|||
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 variant="danger" onClick={handleDelete}>
|
||||
<ButtonV2 variant="danger" disabled={workspaceOperationPending(props.sessionID)} onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
|
|
@ -951,7 +1166,12 @@ export function MessageTimeline(props: {
|
|||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
disabled={workspaceOperationPending(props.sessionID)}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1081,7 +1301,8 @@ export function MessageTimeline(props: {
|
|||
data-timeline-row={input.row()._tag}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
"md:max-w-[720px]": props.centered && settings.general.newLayoutDesigns(),
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
|
||||
"md:mx-auto": props.centered,
|
||||
"pt-3": previousAssistantPart(),
|
||||
}}
|
||||
|
|
@ -1104,7 +1325,7 @@ export function MessageTimeline(props: {
|
|||
)
|
||||
return (
|
||||
<TimelineRowFrame row={commentStripRow}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class={`w-full pb-2 ${turnPadding()}`}>
|
||||
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
|
|
@ -1155,7 +1376,7 @@ export function MessageTimeline(props: {
|
|||
<TimelineRowFrame row={userMessageRow}>
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<Message
|
||||
message={message()}
|
||||
|
|
@ -1171,11 +1392,62 @@ export function MessageTimeline(props: {
|
|||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "WorkspaceLifecycle": {
|
||||
const workspaceRow = row as Accessor<TimelineRowByTag<"WorkspaceLifecycle">>
|
||||
const operation = createMemo(() => {
|
||||
const rowOperation = workspaceRow().notice.operation
|
||||
const current = WorkspaceOperation.get(serverSDK().scope, sessionID()!)
|
||||
if (current?.messageID === workspaceRow().userMessageID) return current
|
||||
if (
|
||||
!rowOperation.messageID &&
|
||||
current?.directory === rowOperation.directory &&
|
||||
current.type === rowOperation.type
|
||||
)
|
||||
return current
|
||||
return rowOperation
|
||||
})
|
||||
const pending = () => operation().status === "pending"
|
||||
const status = () => {
|
||||
if (operation().status === "failed") return language.t("workspace.move.failed")
|
||||
if (operation().type === "create")
|
||||
return language.t(pending() ? "workspace.lifecycle.creating" : "workspace.lifecycle.created")
|
||||
return language.t(pending() ? "workspace.lifecycle.moving" : "workspace.lifecycle.set")
|
||||
}
|
||||
const directory = () => getFilename(operation().directory)
|
||||
return (
|
||||
<TimelineRowFrame row={workspaceRow}>
|
||||
<div class={`w-full ${turnPadding()}`} aria-live="polite">
|
||||
<div class="flex h-11 w-full items-center gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<span class="h-px min-w-0 flex-1 bg-v2-border-border-muted" />
|
||||
<Show when={!pending()} fallback={<TextShimmer text={status()} />}>
|
||||
<span
|
||||
classList={{
|
||||
"flex min-w-0 items-center gap-1.5": true,
|
||||
"text-v2-state-fg-danger": operation().status === "failed",
|
||||
}}
|
||||
>
|
||||
<Show when={operation().status !== "failed"}>
|
||||
<IconV2 name="workspace-isolated" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Show>
|
||||
<Show when={directory()}>
|
||||
<span class="max-w-[240px] truncate text-v2-text-text-base">{directory()}</span>
|
||||
</Show>
|
||||
<span class={operation().status === "failed" ? "" : "text-v2-text-text-faint lowercase"}>
|
||||
{status()}
|
||||
</span>
|
||||
</span>
|
||||
</Show>
|
||||
<span class="h-px min-w-0 flex-1 bg-v2-border-border-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "TurnDivider": {
|
||||
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
||||
return (
|
||||
<TimelineRowFrame row={turnDividerRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<div data-slot="session-turn-compaction">
|
||||
<MessageDivider
|
||||
label={language.t(
|
||||
|
|
@ -1191,7 +1463,7 @@ export function MessageTimeline(props: {
|
|||
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
|
||||
return (
|
||||
<TimelineRowFrame row={assistantPartRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<div
|
||||
data-slot="session-turn-assistant-content"
|
||||
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
|
||||
|
|
@ -1206,7 +1478,7 @@ export function MessageTimeline(props: {
|
|||
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
|
||||
return (
|
||||
<TimelineRowFrame row={thinkingRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<TimelineThinkingRow
|
||||
reasoningHeading={thinkingRow().reasoningHeading}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
|
|
@ -1219,7 +1491,7 @@ export function MessageTimeline(props: {
|
|||
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
||||
return (
|
||||
<TimelineRowFrame row={retryRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
|
|
@ -1227,10 +1499,35 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
case "DiffSummary": {
|
||||
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
|
||||
const canMove = () =>
|
||||
settings.general.newLayoutDesigns() &&
|
||||
diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id &&
|
||||
!workspaceSession() &&
|
||||
props.workspaceMoveEligible &&
|
||||
sync().project?.vcs === "git" &&
|
||||
sessionStatus().type === "idle"
|
||||
return (
|
||||
<TimelineRowFrame row={diffSummaryRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<TimelineDiffSummaryRow
|
||||
diffs={diffSummaryRow().diffs}
|
||||
action={
|
||||
<Show when={canMove() && sync().project}>
|
||||
{(project) => (
|
||||
<WorkspaceMoveAction
|
||||
variant="inline"
|
||||
eligible={props.workspaceMoveEligible}
|
||||
sessionID={sessionID()!}
|
||||
project={project()}
|
||||
directory={sessionDirectory()}
|
||||
messageID={diffSummaryRow().userMessageID}
|
||||
dismissed={workspaceSuggestionDismissed()}
|
||||
onDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
|
|
@ -1239,7 +1536,7 @@ export function MessageTimeline(props: {
|
|||
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
|
||||
return (
|
||||
<TimelineRowFrame row={errorRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||
<Card variant="error" class="error-card">
|
||||
{errorRow().text}
|
||||
</Card>
|
||||
|
|
@ -1321,7 +1618,7 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div class="relative w-full h-full min-w-0" data-workspace-session={workspaceSession() ? "" : undefined}>
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
|
|
@ -1416,6 +1713,39 @@ export function MessageTimeline(props: {
|
|||
}}
|
||||
>
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
<Show when={settings.general.newLayoutDesigns()}>
|
||||
<Show
|
||||
when={workspaceOperation()?.status !== "pending"}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<WorkspaceLocationLoader />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={workspaceSession()}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<IconV2 name="monitor" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<TooltipV2
|
||||
placement="bottom-start"
|
||||
value={sessionDirectory()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={sessionDirectory()}
|
||||
class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted"
|
||||
>
|
||||
<IconV2 name="workspace-isolated" />
|
||||
</span>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1501,6 +1831,47 @@ export function MessageTimeline(props: {
|
|||
placement="bottom"
|
||||
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
|
||||
/>
|
||||
<Show when={settings.general.newLayoutDesigns() && !parentID() && sync().project}>
|
||||
{(project) => (
|
||||
<KobaltePopover
|
||||
open={summaryOpen()}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onOpenChange={setSummary}
|
||||
>
|
||||
<KobaltePopover.Trigger
|
||||
as={IconButtonV2}
|
||||
icon={<IconV2 name="review" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={summaryOpen() ? "pressed" : undefined}
|
||||
aria-label="Session details"
|
||||
aria-expanded={summaryOpen()}
|
||||
/>
|
||||
<KobaltePopover.Portal>
|
||||
<KobaltePopover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
|
||||
<SessionSummaryPanel
|
||||
project={project()}
|
||||
directory={sessionDirectory()}
|
||||
local={!workspaceSession()}
|
||||
branch={sync().data.vcs?.branch}
|
||||
baseBranch={serverSync().child(project().worktree)[0].vcs?.branch}
|
||||
diffs={sessionDiffs()}
|
||||
sessionID={id}
|
||||
moveEligible={props.workspaceMoveEligible}
|
||||
messageID={props.userMessages.at(-1)?.id}
|
||||
moveDismissed={workspaceSuggestionDismissed()}
|
||||
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
onReview={() => {
|
||||
setSummary(false)
|
||||
command.trigger("review.toggle")
|
||||
}}
|
||||
/>
|
||||
</KobaltePopover.Content>
|
||||
</KobaltePopover.Portal>
|
||||
</KobaltePopover>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!parentID()}>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
|
|
@ -1566,11 +1937,15 @@ export function MessageTimeline(props: {
|
|||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||
<DropdownMenu.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => void archiveSession(id)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
|
|
@ -1637,11 +2012,17 @@ export function MessageTimeline(props: {
|
|||
{language.t("session.share.action.share")}...
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||
<MenuV2.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => void archiveSession(id)}
|
||||
>
|
||||
{language.t("common.archive")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
||||
<MenuV2.Item
|
||||
disabled={workspaceOperationPending(id)}
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||
>
|
||||
{language.t("common.delete")}...
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { reuseTimelineRows } from "./row-reconciliation"
|
||||
import { appendTurnExtensions, reuseTimelineRows } from "./row-reconciliation"
|
||||
import { TimelineRow } from "./timeline-row"
|
||||
|
||||
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
|
||||
|
|
@ -94,3 +94,20 @@ describe("reuseTimelineRows", () => {
|
|||
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
|
||||
})
|
||||
})
|
||||
|
||||
test("appends turn extensions after projected turn rows", () => {
|
||||
const rows: TimelineRow.TimelineRow[] = [user(), new TimelineRow.DiffSummary({ userMessageID: "user-1", diffs: [] })]
|
||||
const lifecycle = new TimelineRow.WorkspaceLifecycle({
|
||||
userMessageID: "user-1",
|
||||
notice: {
|
||||
type: "operation",
|
||||
operation: { type: "move", status: "complete", directory: "/workspace", messageID: "user-1" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(appendTurnExtensions(rows, [lifecycle]).map((row) => row._tag)).toEqual([
|
||||
"UserMessage",
|
||||
"DiffSummary",
|
||||
"WorkspaceLifecycle",
|
||||
])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export function createTimelineProjection(input: {
|
|||
status: Accessor<SessionStatus>
|
||||
showReasoningSummaries: Accessor<boolean>
|
||||
inlineComments: Accessor<boolean>
|
||||
extensionRevision?: Accessor<unknown>
|
||||
afterTurn?: (message: UserMessage) => TimelineRow.TimelineRow[]
|
||||
}) {
|
||||
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
|
||||
const assistantMessagesByParent = createMemo(() => {
|
||||
|
|
@ -29,8 +31,9 @@ export function createTimelineProjection(input: {
|
|||
})
|
||||
return result
|
||||
})
|
||||
const projection = createMemo(() =>
|
||||
Timeline.constructSessionMessageRows(
|
||||
const projection = createMemo(() => {
|
||||
const extension = input.extensionRevision?.()
|
||||
return Timeline.constructSessionMessageRows(
|
||||
input.sessionMessages(),
|
||||
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
|
||||
input.parts,
|
||||
|
|
@ -38,8 +41,9 @@ export function createTimelineProjection(input: {
|
|||
input.status().type,
|
||||
input.inlineComments(),
|
||||
input.userMessages(),
|
||||
),
|
||||
)
|
||||
input.extensionRevision && extension === undefined ? undefined : input.afterTurn,
|
||||
)
|
||||
})
|
||||
const activeMessageID = createMemo(() => projection().activeMessageID)
|
||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
reuseTimelineRows(previous, projection().rows),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ import { TimelineRow } from "./timeline-row"
|
|||
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
|
||||
type PriorContext = { index: number; row: ContextRow }
|
||||
|
||||
export function appendTurnExtensions(rows: TimelineRow.TimelineRow[], extensions: TimelineRow.TimelineRow[]) {
|
||||
rows.push(...extensions)
|
||||
return rows
|
||||
}
|
||||
|
||||
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
|
||||
if (!previous?.length) return rows
|
||||
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
|
||||
|
|
|
|||
|
|
@ -167,4 +167,36 @@ describe("current session timeline rows", () => {
|
|||
"thinking:msg_2",
|
||||
])
|
||||
})
|
||||
|
||||
test("appends lifecycle extensions after current turns", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "question", time: { created: 1 } },
|
||||
] satisfies SessionMessageInfo[]
|
||||
const normalized = normalizeSessionMessages("ses_1", source)
|
||||
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
source,
|
||||
(messageID) => messages.get(messageID),
|
||||
(messageID) => normalized.parts.get(messageID) ?? [],
|
||||
true,
|
||||
"idle",
|
||||
true,
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
(message) => [
|
||||
new TimelineRow.WorkspaceLifecycle({
|
||||
userMessageID: message.id,
|
||||
notice: {
|
||||
type: "operation",
|
||||
operation: { type: "move", status: "complete", directory: "/workspace", messageID: message.id },
|
||||
},
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_user",
|
||||
"workspace-lifecycle:msg_user:operation",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai
|
|||
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
import { uniqueSummaryDiffs } from "./summary-diffs"
|
||||
import { appendTurnExtensions } from "./row-reconciliation"
|
||||
|
||||
export { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
|
||||
|
|
@ -27,6 +28,10 @@ export type TimelineRowMap = {
|
|||
}
|
||||
Thinking: { userMessageID: string; reasoningHeading?: string }
|
||||
Retry: { userMessageID: string }
|
||||
WorkspaceLifecycle: {
|
||||
userMessageID: string
|
||||
notice: TimelineRow.WorkspaceLifecycle["notice"]
|
||||
}
|
||||
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
|
||||
Error: { userMessageID: string; text: string }
|
||||
}
|
||||
|
|
@ -40,6 +45,7 @@ export namespace Timeline {
|
|||
status: SessionStatus["type"],
|
||||
inlineComments: boolean,
|
||||
projectedUserMessages: UserMessage[],
|
||||
afterTurn?: (message: UserMessage) => TimelineRow.TimelineRow[],
|
||||
) {
|
||||
const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = []
|
||||
const turnByUserID = new Map<string, (typeof turns)[number]>()
|
||||
|
|
@ -82,8 +88,8 @@ export namespace Timeline {
|
|||
const activeMessageID = turns.at(-1)?.user.id
|
||||
return {
|
||||
activeMessageID,
|
||||
rows: turns.flatMap((turn, index) =>
|
||||
constructMessageRows(
|
||||
rows: turns.flatMap((turn, index) => {
|
||||
const rows = constructMessageRows(
|
||||
turn.user,
|
||||
getMessageParts,
|
||||
turn.assistants,
|
||||
|
|
@ -92,8 +98,10 @@ export namespace Timeline {
|
|||
status,
|
||||
turn.user.id === activeMessageID,
|
||||
inlineComments,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!afterTurn) return rows
|
||||
return appendTurnExtensions(rows, afterTurn(turn.user))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { Data, Equal } from "effect"
|
||||
import type { WorkspaceOperationState } from "@/utils/workspace-operation"
|
||||
|
||||
export type SummaryDiff = SnapshotFileDiff & { file: string }
|
||||
|
||||
|
|
@ -39,6 +40,10 @@ export namespace TimelineRow {
|
|||
export class Retry extends Data.TaggedClass("Retry")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class WorkspaceLifecycle extends Data.TaggedClass("WorkspaceLifecycle")<{
|
||||
userMessageID: string
|
||||
notice: { type: "operation"; operation: WorkspaceOperationState }
|
||||
}> {}
|
||||
|
||||
export type TimelineRow =
|
||||
| TurnGap
|
||||
|
|
@ -50,6 +55,7 @@ export namespace TimelineRow {
|
|||
| DiffSummary
|
||||
| Error
|
||||
| Retry
|
||||
| WorkspaceLifecycle
|
||||
|
||||
export const key = (row: TimelineRow) => {
|
||||
switch (row._tag) {
|
||||
|
|
@ -71,6 +77,8 @@ export namespace TimelineRow {
|
|||
return `error:${row.userMessageID}`
|
||||
case "Retry":
|
||||
return `retry:${row.userMessageID}`
|
||||
case "WorkspaceLifecycle":
|
||||
return `workspace-lifecycle:${row.userMessageID}:${row.notice.type}`
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
|
|||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||
|
||||
export type SessionCommandContext = {
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
|
|
@ -73,6 +74,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
if (!id) return
|
||||
return sync().session.get(id)
|
||||
}
|
||||
const workspaceOperationPending = (sessionID: string) =>
|
||||
WorkspaceOperation.get(sdk().scope, sessionID)?.status === "pending"
|
||||
const hasReview = () => !!params.id
|
||||
const normalizeTab = (tab: string) => {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
|
|
@ -305,6 +308,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
const undo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const session = sdk().api.session
|
||||
const directory = sdk().directory
|
||||
|
|
@ -333,6 +337,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
const redo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
const owner = sessionOwnership.capture()
|
||||
const session = sdk().api.session
|
||||
const messages = userMessages()
|
||||
|
|
@ -365,6 +370,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
const compact = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
|
||||
const model = local.model.current()
|
||||
if (!model) {
|
||||
|
|
@ -382,6 +388,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
}
|
||||
|
||||
const fork = () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (workspaceOperationPending(sessionID)) return
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-fork"),
|
||||
(x) => dialog.show(() => <x.DialogFork />),
|
||||
|
|
@ -431,7 +440,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id),
|
||||
onSelect: undo,
|
||||
}),
|
||||
sessionCommand({
|
||||
|
|
@ -439,7 +448,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
disabled: !params.id || !info()?.revert?.messageID,
|
||||
disabled: !params.id || !info()?.revert?.messageID || workspaceOperationPending(params.id),
|
||||
onSelect: redo,
|
||||
}),
|
||||
sessionCommand({
|
||||
|
|
@ -447,7 +456,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
title: language.t("command.session.compact"),
|
||||
description: language.t("command.session.compact.description"),
|
||||
slash: "compact",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id),
|
||||
onSelect: compact,
|
||||
}),
|
||||
sessionCommand({
|
||||
|
|
@ -455,7 +464,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
title: language.t("command.session.fork"),
|
||||
description: language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id),
|
||||
onSelect: fork,
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
39
packages/app/src/utils/workspace-operation.test.ts
Normal file
39
packages/app/src/utils/workspace-operation.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerScope } from "./server-scope"
|
||||
import { canMoveSessionToWorkspace, WorkspaceOperation } from "./workspace-operation"
|
||||
|
||||
test("workspace moves require settled followup state", () => {
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: false })).toBe(true)
|
||||
expect(canMoveSessionToWorkspace({ queued: 1, failed: false, paused: false, editing: false })).toBe(false)
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: true, paused: false, editing: false })).toBe(false)
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: true, editing: false })).toBe(false)
|
||||
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: true })).toBe(false)
|
||||
})
|
||||
|
||||
describe("WorkspaceOperation", () => {
|
||||
test("reacts to operation completion", () => {
|
||||
WorkspaceOperation.start(ServerScope.local, "session", "move", "/workspace")
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending")
|
||||
WorkspaceOperation.complete(ServerScope.local, "session")
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("complete")
|
||||
})
|
||||
|
||||
test("ignores completion for a different destination", () => {
|
||||
WorkspaceOperation.start(ServerScope.local, "destination", "move", "/workspace/expected")
|
||||
WorkspaceOperation.complete(ServerScope.local, "destination", "/workspace/other")
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "destination")?.status).toBe("pending")
|
||||
})
|
||||
|
||||
test("does not downgrade a completed move after cleanup fails", () => {
|
||||
WorkspaceOperation.start(ServerScope.local, "cleanup", "move", "/workspace")
|
||||
WorkspaceOperation.complete(ServerScope.local, "cleanup")
|
||||
WorkspaceOperation.fail(ServerScope.local, "cleanup", "source cleanup failed")
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "cleanup")?.status).toBe("complete")
|
||||
})
|
||||
|
||||
test("settles a pending create from its worktree directory", () => {
|
||||
WorkspaceOperation.start(ServerScope.local, "create", "create", "/workspace/create")
|
||||
WorkspaceOperation.completeCreate(ServerScope.local, "/workspace/create")
|
||||
expect(WorkspaceOperation.get(ServerScope.local, "create")?.status).toBe("complete")
|
||||
})
|
||||
})
|
||||
74
packages/app/src/utils/workspace-operation.ts
Normal file
74
packages/app/src/utils/workspace-operation.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
export type WorkspaceOperationType = "create" | "move"
|
||||
export type WorkspaceOperationState = {
|
||||
type: WorkspaceOperationType
|
||||
status: "pending" | "complete" | "failed"
|
||||
directory: string
|
||||
messageID?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function canMoveSessionToWorkspace(input: {
|
||||
queued: number
|
||||
failed: boolean
|
||||
paused: boolean
|
||||
editing: boolean
|
||||
}) {
|
||||
return input.queued === 0 && !input.failed && !input.paused && !input.editing
|
||||
}
|
||||
|
||||
const state = new Map<string, WorkspaceOperationState>()
|
||||
const [version, setVersion] = createSignal(0)
|
||||
const key = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
|
||||
const write = (scope: ServerScope, sessionID: string, value: WorkspaceOperationState) => {
|
||||
if (!state.has(key(scope, sessionID)) && state.size >= 100) {
|
||||
const terminal = [...state].find(([, item]) => item.status !== "pending")?.[0] ?? state.keys().next().value
|
||||
if (terminal) state.delete(terminal)
|
||||
}
|
||||
state.set(key(scope, sessionID), value)
|
||||
setVersion((current) => current + 1)
|
||||
}
|
||||
const settleCreate = (scope: ServerScope, directory: string, status: "complete" | "failed", message?: string) => {
|
||||
const prefix = ScopedKey.prefix(scope)
|
||||
const target = pathKey(directory)
|
||||
const matches = [...state].filter(
|
||||
([id, item]) =>
|
||||
id.startsWith(prefix) &&
|
||||
item.type === "create" &&
|
||||
item.status === "pending" &&
|
||||
pathKey(item.directory) === target,
|
||||
)
|
||||
if (matches.length === 0) return
|
||||
matches.forEach(([id, item]) => state.set(id, { ...item, status, message }))
|
||||
setVersion((current) => current + 1)
|
||||
}
|
||||
|
||||
export const WorkspaceOperation = {
|
||||
get(scope: ServerScope, sessionID: string) {
|
||||
version()
|
||||
return state.get(key(scope, sessionID))
|
||||
},
|
||||
start(scope: ServerScope, sessionID: string, type: WorkspaceOperationType, directory: string, messageID?: string) {
|
||||
write(scope, sessionID, { type, directory, messageID, status: "pending" })
|
||||
},
|
||||
complete(scope: ServerScope, sessionID: string, directory?: string) {
|
||||
const current = state.get(key(scope, sessionID))
|
||||
if (!current) return
|
||||
if (directory && pathKey(directory) !== pathKey(current.directory)) return
|
||||
write(scope, sessionID, { ...current, status: "complete" })
|
||||
},
|
||||
completeCreate(scope: ServerScope, directory: string) {
|
||||
settleCreate(scope, directory, "complete")
|
||||
},
|
||||
failCreate(scope: ServerScope, directory: string, message: string) {
|
||||
settleCreate(scope, directory, "failed", message)
|
||||
},
|
||||
fail(scope: ServerScope, sessionID: string, message: string) {
|
||||
const current = state.get(key(scope, sessionID))
|
||||
if (!current || current.status === "complete") return
|
||||
write(scope, sessionID, { ...current, status: "failed", message })
|
||||
},
|
||||
}
|
||||
36
packages/app/src/utils/workspace-request.test.ts
Normal file
36
packages/app/src/utils/workspace-request.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { workspaceRequestWithTimeout } from "./workspace-request"
|
||||
|
||||
test("workspace requests abort at the bounded timeout", async () => {
|
||||
let aborted = false
|
||||
const request = workspaceRequestWithTimeout(
|
||||
(signal) =>
|
||||
new Promise<never>((_, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
aborted = true
|
||||
reject(signal.reason)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}),
|
||||
"Workspace request failed",
|
||||
1,
|
||||
)
|
||||
|
||||
await expect(request).rejects.toThrow("Workspace request failed")
|
||||
expect(aborted).toBe(true)
|
||||
})
|
||||
|
||||
test("workspace requests preserve non-timeout failures", async () => {
|
||||
await expect(
|
||||
workspaceRequestWithTimeout(() => Promise.reject(new Error("Server failed")), "Workspace request failed", 1_000),
|
||||
).rejects.toThrow("Server failed")
|
||||
})
|
||||
|
||||
test("workspace request timeout settles when the request ignores abort", async () => {
|
||||
await expect(
|
||||
workspaceRequestWithTimeout(() => new Promise<never>(() => {}), "Workspace request failed", 1),
|
||||
).rejects.toThrow("Workspace request failed")
|
||||
})
|
||||
25
packages/app/src/utils/workspace-request.ts
Normal file
25
packages/app/src/utils/workspace-request.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export const WORKSPACE_PREPARATION_TIMEOUT_MS = 5 * 60 * 1000
|
||||
export const WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS = 30_000
|
||||
|
||||
export async function workspaceRequestWithTimeout<T>(
|
||||
request: (signal: AbortSignal) => Promise<T>,
|
||||
message: string,
|
||||
timeoutMs: number,
|
||||
) {
|
||||
const controller = new AbortController()
|
||||
const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer.id = setTimeout(() => {
|
||||
controller.abort()
|
||||
reject(new Error(message))
|
||||
}, timeoutMs)
|
||||
})
|
||||
return Promise.race([request(controller.signal), timeout])
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) throw new Error(message)
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
if (timer.id !== undefined) clearTimeout(timer.id)
|
||||
})
|
||||
}
|
||||
35
packages/app/src/utils/workspace.test.ts
Normal file
35
packages/app/src/utils/workspace.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { isWorkspaceDirectory, isWorkspaceSelection } from "./workspace"
|
||||
|
||||
describe("isWorkspaceDirectory", () => {
|
||||
const project = {
|
||||
worktree: "C:\\repo\\",
|
||||
sandboxes: ["C:\\repo-workspaces\\feature\\", "C:\\repo-workspaces\\other"],
|
||||
}
|
||||
|
||||
test("distinguishes managed workspaces from the local repository", () => {
|
||||
expect(isWorkspaceDirectory(project, "C:\\repo")).toBe(false)
|
||||
expect(isWorkspaceDirectory(project, "C:\\repo-workspaces\\feature")).toBe(true)
|
||||
expect(isWorkspaceDirectory(project, "c:\\repo-workspaces\\feature\\packages\\app")).toBe(true)
|
||||
})
|
||||
|
||||
test("does not classify unknown directories", () => {
|
||||
expect(isWorkspaceDirectory(project, "C:\\other")).toBe(false)
|
||||
expect(isWorkspaceDirectory(undefined, "C:\\repo-workspaces\\feature")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isWorkspaceSelection", () => {
|
||||
const project = { worktree: "/repo", sandboxes: ["/workspaces/feature"] }
|
||||
|
||||
test("accepts local, new, and managed workspace selections", () => {
|
||||
expect(isWorkspaceSelection(project, "main")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "create")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "/repo/")).toBe(true)
|
||||
expect(isWorkspaceSelection(project, "/workspaces/feature/")).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects selections from a different project", () => {
|
||||
expect(isWorkspaceSelection(project, "/other/workspace")).toBe(false)
|
||||
})
|
||||
})
|
||||
25
packages/app/src/utils/workspace.ts
Normal file
25
packages/app/src/utils/workspace.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
type WorkspaceProject = { worktree: string; sandboxes?: readonly string[] }
|
||||
|
||||
export function isWorkspaceDirectory(project: WorkspaceProject | undefined, directory: string) {
|
||||
if (!project || containsDirectory(project.worktree, directory)) return false
|
||||
return project.sandboxes?.some((workspace) => containsDirectory(workspace, directory)) ?? false
|
||||
}
|
||||
|
||||
export function containsDirectory(parent: string, child: string) {
|
||||
const normalize = (value: string) => {
|
||||
const key = pathKey(value)
|
||||
return /^[a-z]:\//i.test(key) || key.startsWith("//") ? key.toLowerCase() : key
|
||||
}
|
||||
const root = normalize(parent)
|
||||
const target = normalize(child)
|
||||
return target === root || target.startsWith(root.endsWith("/") ? root : `${root}/`)
|
||||
}
|
||||
|
||||
export function isWorkspaceSelection(project: WorkspaceProject | undefined, selection: string) {
|
||||
if (selection === "main" || selection === "create") return true
|
||||
if (!project) return false
|
||||
if (pathKey(project.worktree) === pathKey(selection)) return true
|
||||
return isWorkspaceDirectory(project, selection)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue