Compare commits

...

11 Commits

Author SHA1 Message Date
Kit Langton 05e9818285 refactor(core): defer webfetch HTML parsing 2026-08-13 21:40:39 -04:00
Kit Langton a5b6ead43e perf(util): load npm config lazily (#42458) 2026-08-13 21:19:32 -04:00
Kit Langton 7a73aad0a2 chore: remove orphaned v2 exports (#42459) 2026-08-13 21:18:58 -04:00
Kit Langton 304c85fb2b refactor(util): remove xdg-basedir dependency (#42462) 2026-08-13 21:18:48 -04:00
Kit Langton 85988b957b refactor(core): trim sqlite adapter paths (#42457) 2026-08-13 21:18:05 -04:00
Kit Langton 146aeef830 refactor(util): remove unused npm install (#42454) 2026-08-13 21:17:52 -04:00
Kit Langton 28b630477d fix(tui): recover sessions from missing locations (#42455) 2026-08-13 20:49:18 -04:00
Kit Langton 5cd7705036 fix(tui): correct tab context menu behavior (#42453) 2026-08-13 20:39:26 -04:00
Kit Langton 5816fcbc61 fix(tui): preserve toast hover state (#42419) 2026-08-13 20:31:37 -04:00
Luke Parker 655b3c4c22 feat(app): add workspace flows to new layout (#38790) 2026-08-14 10:19:45 +10:00
Dax 8023ba378b fix(cli): defer update check until service resolves (#42446) 2026-08-14 00:13:33 +00:00
86 changed files with 3080 additions and 1375 deletions
-4
View File
@@ -1038,7 +1038,6 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"resolve.exports": "catalog:",
"xdg-basedir": "5.1.0",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -1104,7 +1103,6 @@
"unenv": "2.0.0-rc.24",
"vitest": "3.2.7",
"wrangler": "4.28.0",
"xdg-basedir": "5.1.0",
},
},
"packages/www": {
@@ -5999,8 +5997,6 @@
"xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="],
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
"xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="],
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
+2
View File
@@ -22,6 +22,8 @@
## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
+39 -8
View File
@@ -267,16 +267,34 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/project") return json(route, [config.project])
if (path === "/api/project") {
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return json(route, [
{
...project,
canonical: project.canonical ?? project.worktree ?? config.directory,
},
])
}
if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
const worktree = path.match(/^\/api\/experimental\/project\/([^/]+)\/worktree$/)?.[1]
if (worktree && route.request().method() === "GET")
return json(route, [
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git",
})),
])
if (path === "/api/location") return json(route, location(config))
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
if (projectCopy && route.request().method() === "POST") {
if (worktree && route.request().method() === "POST") {
const input = route.request().postDataJSON() as { directory: string; name?: string }
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
}
if (projectCopy && route.request().method() === "DELETE")
if (worktree && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/experimental\/project\/[^/]+\/worktree\/refresh$/.test(path))
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/permission/request")
return json(route, {
@@ -343,7 +361,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions
.filter((session) => !directory || session.directory === directory)
.filter((session) => {
const location = session.location as { directory?: string } | undefined
return !directory || location?.directory === directory || session.directory === directory
})
.filter((session) => parentID !== "null" || session.parentID === undefined)
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
@@ -578,6 +599,7 @@ function currentPermission(value: unknown) {
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
const time = session.time && typeof session.time === "object" ? session.time : {}
const location = session.location && typeof session.location === "object" ? session.location : {}
return {
id: session.id,
parentID: session.parentID,
@@ -595,10 +617,19 @@ export function currentSession(session: { id: string } & Record<string, unknown>
},
title: session.title ?? session.id,
location: {
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
directory:
"directory" in location && typeof location.directory === "string"
? location.directory
: typeof session.directory === "string"
? session.directory
: fallbackDirectory,
...(typeof session.workspaceID === "string"
? { workspaceID: session.workspaceID }
: "workspaceID" in location && typeof location.workspaceID === "string"
? { workspaceID: location.workspaceID }
: {}),
},
subpath: session.path,
subpath: session.subpath ?? session.path,
revert: session.revert,
}
}
+8 -1
View File
@@ -1,6 +1,7 @@
import "@/index.css"
import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context"
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { File } from "@opencode-ai/session-ui/file"
@@ -159,7 +160,13 @@ function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return (
<I18nProvider
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
value={{
locale: language.intl,
layoutLocale: language.layoutLocale,
t: language.t as UiI18n["t"],
plural: language.plural,
pluralForm: language.pluralForm,
}}
>
{props.children}
</I18nProvider>
@@ -37,6 +37,7 @@ export type PromptInputV2ComposerProps = {
class?: string
controller: PromptInputV2ComposerController
borderUnderlay?: boolean
accentSubmit?: boolean
}
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
@@ -53,6 +54,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
<div class="flex flex-col gap-3">
<PromptInputV2
controller={props.controller}
accentSubmit={props.accentSubmit}
borderUnderlay={props.borderUnderlay}
class={props.class}
variantControlVisible={!props.controller.model.loading}
@@ -1,18 +1,16 @@
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 { ServerScope } from "@/utils/server-scope"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const sessionCreateInputs: Array<{
type SessionCreateInput = {
agent?: string
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}> = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
}
const optimistic: Array<{
directory?: string
sessionID?: string
@@ -22,11 +20,9 @@ const optimistic: Array<{
variant?: string
}
}> = []
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 syncedDirectories: string[] = []
const sentShellDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
@@ -37,15 +33,29 @@ const switchedModels: Array<{
model: { id: string; providerID: string; variant?: string }
}> = []
const sessionRequestOrder: string[] = []
const commands: Array<{ name: 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 } = {}
let search: { draftId?: string } = {}
let selected = "/repo/worktree-a"
let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined
let locationFailure: Error | undefined
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> = {}
const sessionDirectories: Record<string, string> = {}
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({
@@ -73,21 +83,25 @@ 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)
return {
api: {
session: {
create: async (input: (typeof sessionCreateInputs)[number]) => {
create: async (input: SessionCreateInput) => {
await createSessionGate
const location = input.location?.directory ?? directory
createdSessions.push(location)
sessionCreateInputs.push(input)
const id = `session-${createdSessions.length}`
sessionDirectories[id] = location
return {
id: `session-${createdSessions.length}`,
id,
projectID: "project",
agent: input.agent,
model: input.model,
@@ -100,7 +114,7 @@ const clientFor = (directory: string) => {
},
prompt: async (input: unknown) => {
sessionRequestOrder.push("prompt")
sentPrompts.push(directory)
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
promptInputs.push(input)
return { data: undefined }
},
@@ -120,6 +134,21 @@ const clientFor = (directory: string) => {
},
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
sentShellDirectories.push(sessionDirectories[input.sessionID] ?? directory)
},
},
worktree: {
create: async (_input: unknown) => {
worktreeCreates++
await createWorktreeGate
if (worktreeFailure) throw worktreeFailure
return { directory: worktreeDirectory }
},
},
location: {
get: async () => {
if (locationFailure) throw locationFailure
return { directory: worktreeDirectory }
},
},
},
@@ -127,9 +156,6 @@ const clientFor = (directory: string) => {
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
},
}
}
@@ -145,6 +171,7 @@ beforeAll(async () => {
mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null },
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
showToast: () => 0,
}))
@@ -162,20 +189,13 @@ beforeAll(async () => {
current: () => ({ name: "agent" }),
},
session: {
promote(directory: string, sessionID: string) {
promoted.push({ directory, sessionID })
},
promote: () => undefined,
},
}),
}))
mock.module("@/context/permission", () => {
const state = (server: string) => ({
enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ server, sessionID, directory })
},
})
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
})
mock.module("@/context/server", () => ({
@@ -184,7 +204,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 })
},
@@ -205,68 +228,70 @@ 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",
api: rootClient.api,
url: "http://localhost:4096",
}
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, project: "project" },
session: {
optimistic: {
add: (value: {
directory?: string
sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
}) => {
optimisticServers.push(server)
optimistic.push(value)
},
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)
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", () => ({
@@ -286,205 +311,141 @@ beforeAll(async () => {
})
beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0
sessionCreateInputs.length = 0
enabledAutoAccept.length = 0
optimistic.length = 0
optimisticSeeded.length = 0
promoted.length = 0
promotedDrafts.length = 0
updatedDrafts.length = 0
sentCommands.length = 0
sentPrompts.length = 0
promptInputs.length = 0
sentCommands.length = 0
switchedAgents.length = 0
switchedModels.length = 0
sessionRequestOrder.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
syncedDirectories.length = 0
sentShellDirectories.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
createWorktreeGate = undefined
worktreeFailure = undefined
locationFailure = undefined
worktreeCreates = 0
for (const key of Object.keys(draftServers)) delete draftServers[key]
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
})
const event = { preventDefault: () => undefined } as unknown as Event
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
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,
...overrides,
})
describe("prompt submit worktree selection", () => {
test("reads the latest worktree accessor value per submit", async () => {
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,
test("admits only one concurrent new-workspace submission", async () => {
selected = "create"
let release = () => {}
createWorktreeGate = new Promise<void>((resolve) => {
release = resolve
})
const submit = makeSubmit()
const event = { preventDefault: () => undefined } as unknown as Event
const first = submit.handleSubmit(event)
const duplicate = submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
await submit.handleSubmit(event)
selected = "/repo/worktree-b"
await submit.handleSubmit(event)
release()
await Promise.all([first, duplicate])
expect(createdSessions).toEqual([worktreeDirectory])
await settle()
expect(createdClients).toEqual([])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sessionCreateInputs).toEqual([
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-a" },
},
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-b" },
},
])
expect(sentShell).toEqual([
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(serverSessionSyncs).toBe(0)
expect(promoted).toEqual([
{ directory: "/repo/worktree-a", sessionID: "session-1" },
{ directory: "/repo/worktree-b", sessionID: "session-2" },
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(worktreeCreates).toBe(1)
expect(createdSessions).toHaveLength(1)
expect(sentPrompts).toEqual([worktreeDirectory])
})
test("applies auto-accept to newly created sessions", async () => {
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
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,
})
test("stops when the created workspace cannot initialize", async () => {
selected = "create"
locationFailure = new Error("initialization failed")
const event = { preventDefault: () => undefined } as unknown as Event
await makeSubmit().handleSubmit(event)
await submit.handleSubmit(event)
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
expect(worktreeCreates).toBe(1)
expect(createdSessions).toEqual([])
expect(sentPrompts).toEqual([])
})
test("keeps auto-accept bound to the submission server", async () => {
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
})
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
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,
let submitted = 0
const submit = makeSubmit({
onSubmit: () => submitted++,
})
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
permissionServer = "server-b"
const result = submit.handleSubmit(event)
activeSDK = "server-b"
activeServerSync = "server-b"
activeDirectorySync = "server-b"
search.draftId = "draft-2"
release()
await result
await settle()
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
})
test("promotes drafts using the selected project's server", async () => {
search = { draftId: "draft-1" }
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(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
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(submitted).toBe(0)
})
test("switches the selected agent and model before prompting", async () => {
params = { id: "session-1" }
variant = "high"
const submit = createPromptSubmit({
prompt,
const submit = makeSubmit({
info: () => ({
id: "session-1",
agent: "old-agent",
model: { id: "old-model", providerID: "old-provider" },
}),
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,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
await Bun.sleep(0)
@@ -519,24 +480,12 @@ describe("prompt submit worktree selection", () => {
commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
const submit = createPromptSubmit({
prompt,
const submit = makeSubmit({
info: () => ({ id: "session-1" }),
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,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
await submit.handleSubmit(event)
await settle()
expect(sentCommands).toEqual([
{
@@ -552,66 +501,19 @@ describe("prompt submit worktree selection", () => {
expect(serverSessionSyncs).toBe(0)
})
test("uses an injected model selection", async () => {
params = { id: "session-1" }
const model = {
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
variant: { current: () => "draft-variant" },
} as unknown as ModelSelection
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
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,
model,
test("sends an initial shell after synchronous workspace creation", async () => {
selected = "create"
const submit = makeSubmit({
mode: () => "shell",
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(optimistic[0]).toMatchObject({
message: {
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
},
})
})
test("seeds new sessions before optimistic prompts are added", async () => {
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)
await settle()
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
expect(optimisticSeeded).toEqual([true])
expect(sentShellDirectories).toEqual([worktreeDirectory])
expect(sentShell[0]).toMatchObject({
sessionID: "session-1",
command: "ls",
})
})
})
+221 -289
View File
@@ -15,7 +15,6 @@ import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt
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 { getDirectory } from "@opencode-ai/core/util/path"
import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
@@ -25,12 +24,7 @@ import { createPromptSubmissionState } from "./submission-state"
import { Event } from "@opencode-ai/schema/event"
import { blobDataUrl } from "@/utils/draft-store"
type PendingPrompt = {
abort: AbortController
cleanup: VoidFunction
}
const pending = new Map<string, PendingPrompt>()
const submitting = new Set<string>()
export type FollowupDraft = {
sessionID: string
@@ -50,7 +44,6 @@ type FollowupSendInput = {
draft: FollowupDraft
messageID?: string
optimisticBusy?: boolean
before?: () => Promise<boolean> | boolean
}
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
@@ -70,22 +63,11 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
}
const wait = async () => {
const ok = await input.before?.()
if (ok === false) return false
return true
}
const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
setBusy()
try {
if (!(await wait())) {
setIdle()
return false
}
const messageID = Identifier.ascending("message")
await input.api.command({
sessionID: input.draft.sessionID,
@@ -159,14 +141,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
})
try {
if (!(await wait())) {
batch(() => {
setIdle()
remove()
})
return false
}
const session = input.session()
if (session?.agent !== input.draft.agent) {
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
@@ -263,8 +237,6 @@ 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 errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
if (err && typeof err === "object" && "data" in err) {
@@ -278,19 +250,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const abort = async () => {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
serverSync().session.set("todo", sessionID, [])
input.onAbort?.()
const key = pendingKey(sessionID)
const queued = pending.get(key)
if (queued) {
queued.abort.abort()
queued.cleanup()
pending.delete(key)
return Promise.resolve()
}
return sdk()
.api.session.interrupt({ sessionID })
.catch(() => {})
@@ -319,9 +282,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
const seed = (dir: string, info: SessionInfo) => {
serverSync().session.remember(info)
const [, setStore] = serverSync().child(dir)
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
target.session.remember(info)
const [, setStore] = target.child(dir)
setStore("session", (list: SessionInfo[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
@@ -353,7 +316,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (input.working()) void abort()
return
}
const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current()
const currentAgent = local.agent.current()
@@ -366,283 +328,253 @@ 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 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
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await sdk()
.api.worktree.create({
projectID: sync().data.project,
strategy: "git",
directory: getDirectory(projectDirectory),
try {
input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation()
let sessionDirectory = projectDirectory
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await submissionSDK.api.worktree
.create({
projectID: submissionSync.data.project,
strategy: "git",
directory: getDirectory(submissionSync.project?.worktree ?? projectDirectory),
})
.then(async (created) => {
await submissionSDK.api.location.get({ location: { directory: created.directory } })
return created
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
})
})
if (!createdWorktree) return
sessionDirectory = createdWorktree.directory
}
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection
}
if (sessionDirectory !== projectDirectory) {
submissionServerSync.child(sessionDirectory)
}
}
let session = currentSession
if (!session && isNewSession) {
const created = await submissionSDK.api.session
.create({
agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(err),
})
return undefined
})
if (!createdWorktree) return
WorktreeState.pending(sdk().scope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory
}
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection
}
if (sessionDirectory !== projectDirectory) {
serverSync().child(sessionDirectory)
}
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 },
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(err),
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 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 }))
})
}
}
}
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,
}
const clearInput = () => {
submission.clear()
input.setMode("normal")
input.setPopover(null)
}
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 (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft)
clearContext(submission.target())
clearInput()
return
}
input.onSubmit?.()
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
sdk()
.api.session.shell({
sessionID: session.id,
id: eventID,
command: text,
if (!session) {
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: language.t("prompt.toast.promptSendFailed.description"),
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
})
restoreInput()
})
return
}
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) {
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,
}
const clearInput = () => {
submission.clear()
input.setMode("normal")
input.setPopover(null)
}
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 (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft)
clearContext(submission.target())
clearInput()
const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" })
sdk()
.api.session.command({
return
}
if (!draftID || search.draftId === draftID) onSubmit?.()
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
void submissionSDK.api.session
.shell({
sessionID: session.id,
id: messageID,
command: commandName,
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
id: eventID,
command: text,
})
.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" })
}
const controller = new AbortController()
const cleanup = () => {
if (sessionDirectory === projectDirectory) {
sync().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" })
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")
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
void submissionSDK.api.session
.command({
sessionID: session.id,
id: messageID,
command: commandName,
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
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
}
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(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
}
void sendFollowupDraft({
api: sdk().api.session,
sync: sync(),
serverSync: serverSync(),
session: () => input.info() ?? session,
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),
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => {
submissionSync.session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
for (const item of commentItems) submission.target().context.remove(item.key)
clearInput()
void sendFollowupDraft({
api: submissionSDK.api.session,
sync: submissionSync,
serverSync: submissionServerSync,
session: () => session,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
}).catch((err) => {
if (sessionDirectory === projectDirectory) {
submissionSync.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)
})
removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
})
} finally {
submitting.delete(submissionKey)
}
}
return {
@@ -1,35 +1,52 @@
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"
import { sameDirectory } from "@/utils/workspace"
export function PromptWorkspaceSelector(props: {
value: string
projectRoot: string
workspaces: string[]
branch?: string
onboarding?: boolean
onChange: (value: string) => void
onDone: () => void
onViewAll: () => void
}) {
const language = useLanguage()
let pending: string | undefined
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
const [search, setSearch] = createSignal("")
let searchInput: HTMLInputElement | undefined
let focusSearch = false
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
const selected = () => (sameDirectory(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)
if (action?.type === "viewAll") {
props.onViewAll()
return
}
props.onDone()
}
const label = () => {
@@ -41,87 +58,214 @@ export function PromptWorkspaceSelector(props: {
return (
<>
<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>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="w-[180px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
<IconV2 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" />
<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" />
</Show>
</MenuV2.Item>
</MenuV2.Group>
<Show when={props.workspaces.length > 0}>
<MenuV2.Separator />
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<IconV2 name="workspace" />
{language.t("session.new.workspace.existing")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-w-[200px]">
<For each={props.workspaces}>
{(workspace) => (
<MenuV2.Item onSelect={() => select(workspace)}>
<IconV2 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" />
</Show>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
<TooltipV2
placement="top"
openDelay={800}
value={
props.onboarding ? (
<div class="flex flex-col gap-1 text-start">
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
<span>{language.t("workspace.onboarding.title")}</span>
</div>
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
</div>
) : (
language.t("session.new.workspace.trigger.tooltip")
)
}
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
class="min-w-0"
>
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<MenuV2.Trigger
aria-description={language.t("session.new.workspace.trigger.tooltip")}
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">{label()}</span>
<Show when={props.onboarding}>
<span
data-slot="workspace-onboarding-dot"
aria-hidden="true"
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
/>
</Show>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<PromptGitStatus branch={props.branch} />
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="w-[200px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
<Icon name="monitor" />
<TooltipV2
placement="right"
openDelay={800}
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("session.new.workspace.local")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.local.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
</TooltipV2>
<Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
<MenuV2.Item onSelect={() => select("create")}>
<Icon name="workspace-new" />
<TooltipV2
placement="right"
openDelay={800}
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("workspace.new")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.new.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</TooltipV2>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</MenuV2.Group>
<Show
when={props.workspaces.length > 0}
fallback={
<>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</>
}
>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Sub
gutter={0}
overlap
overflowPadding={8}
onOpenChange={(open) => {
if (!open) {
focusSearch = false
return
}
if (!focusSearch || props.workspaces.length < 10) return
focusSearch = false
requestAnimationFrame(() => searchInput?.focus())
}}
>
<MenuV2.SubTrigger
onKeyDown={(event) => {
if (
event.key === "ArrowRight" ||
event.key === "ArrowLeft" ||
event.key === "Enter" ||
event.key === " "
)
focusSearch = true
}}
>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</span>
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<Show when={props.workspaces.length >= 10}>
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(element) => {
searchInput = element
}}
value={search()}
placeholder={language.t("session.new.workspace.search.placeholder")}
aria-label={language.t("session.new.workspace.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)}>
<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" />
</Show>
</MenuV2.Item>
)}
</For>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2>
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
</>
)
}
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
const language = useLanguage()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
if (!props.branch) return undefined
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
return props.branch
}
const icon = () => {
if (props.noGit) return "monitor"
if (props.from) return "branch-out"
return "branch"
}
return (
<Show when={label()}>
{(value) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={value()}
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" />
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>
</>
<TooltipV2
placement="top"
value={value()}
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>
)}
</Show>
)
@@ -0,0 +1,136 @@
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createStore } from "solid-js/store"
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
import type { Project } from "@/types"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useSettingsDialog } from "@/components/settings-dialog"
import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/utils/workspace"
export function SessionWorkspaceMenu(props: {
eligible?: boolean
sessionID: string
project: Project
directory: string
placement?: ComponentProps<typeof MenuV2>["placement"]
gutter?: number
class?: string
contentClass?: string
children: JSX.Element
onOpenChange?: (open: boolean) => void
}) {
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
const blocked = () => props.eligible === false || serverSync().session.data.session_working(props.sessionID)
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
const workspaces = () =>
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
const onOpenChange = (open: boolean) => {
props.onOpenChange?.(open)
if (!open) return
const sdk = serverSDK()
void sdk.api.worktree
.refresh({ projectID: props.project.id })
.then(() => sdk.api.worktree.list({ projectID: props.project.id }))
.then((items) =>
setDirectories(
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
),
)
.catch(() => undefined)
}
const move = async (selection: "create" | string) => {
if (store.selected || blocked()) return
const sdk = serverSDK()
const sessionID = props.sessionID
setStore("selected", selection)
try {
const destination = selection === "create" ? await createWorkspace(props.project, sdk) : selection
if (!destination) return
await sdk.api.session.move({ sessionID, directory: destination })
} catch (error) {
showToast({
variant: "error",
title: language.t("workspace.move.failed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
} finally {
setStore("selected", undefined)
}
}
return (
<MenuV2
placement={props.placement ?? "bottom-end"}
gutter={props.gutter ?? 4}
modal={false}
onOpenChange={onOpenChange}
>
<MenuV2.Trigger class={props.class} disabled={blocked()}>
{props.children}
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
<Icon name="monitor" />
{language.t("session.new.workspace.local")}
</MenuV2.Item>
</Show>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</MenuV2.Item>
<Show when={workspaces().length > 0}>
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<Icon name="workspace-isolated" />
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<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.Group>
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
<MenuV2.Item onSelect={() => openWorkspaces()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
}
async function createWorkspace(project: Project, serverSDK: ReturnType<ReturnType<typeof useServerSDK>>) {
const created = await serverSDK.api.worktree.create({
projectID: project.id,
strategy: "git",
directory: getDirectory(project.worktree),
})
await serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
}
@@ -11,6 +11,7 @@ import { SettingsNotificationsV2 } from "./notifications"
import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models"
import { SettingsServersV2 } from "./servers"
import { SettingsWorkspacesV2 } from "./workspaces"
import { SettingsProjectsV2 } from "./projects"
import { SettingsExtensionsV2 } from "./extensions"
import { SettingsServerScope } from "../settings-server-picker"
@@ -95,6 +96,10 @@ export const DialogSettings: Component<{
<Icon name="folder" />
{language.t("settings.tab.projects")}
</TabsV2.Trigger>
<TabsV2.Trigger value="workspaces">
<Icon name="workspace-isolated" />
{language.t("settings.tab.workspaces")}
</TabsV2.Trigger>
</div>
{/* Group 3: Capabilities & Extensions */}
@@ -140,6 +145,9 @@ export const DialogSettings: Component<{
<SettingsProjectsV2 />
</TabsV2.Content>
<SettingsServerScope directory={directory()}>
<TabsV2.Content value="workspaces" class="settings-v2-panel">
<SettingsWorkspacesV2 activeDirectory={directory()} />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
</TabsV2.Content>
@@ -7,7 +7,7 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useUpdaterAction } from "../updater-action"
import { useSettings } from "@/context/settings"
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
@@ -85,6 +85,34 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
)
}
const WorkspaceDestinationSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [
{ value: "last-used", label: language.t("settings.workspaces.default.lastUsed") },
{ value: "local", label: language.t("settings.workspaces.default.local") },
{ value: "new", label: language.t("settings.workspaces.default.new") },
])
return (
<SettingsRowV2
title={language.t("settings.workspaces.default.title")}
description={language.t("settings.workspaces.default.description")}
>
<SelectV2
appearance="inline"
options={options()}
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
/>
</SettingsRowV2>
)
}
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const language = useLanguage()
const options = createMemo(() =>
@@ -300,6 +328,7 @@ export const SettingsGeneralV2: Component<{
<SettingsListV2>
<LanguageSetting />
<WorkspaceDestinationSetting />
<PermissionScopeSetting controller={permissionScope} />
<ShellSetting controller={shell} />
@@ -362,18 +391,6 @@ export const SettingsGeneralV2: Component<{
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
@@ -692,6 +692,223 @@
color: var(--v2-text-text-base);
}
.settings-v2-tab-header.settings-v2-workspaces-header {
padding-bottom: 24px;
}
.settings-v2-workspaces-header .settings-v2-tab-title {
font-weight: 610;
}
.settings-v2-tab-body.settings-v2-workspaces {
gap: 16px;
}
.settings-v2-workspaces-toolbar {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-workspaces-count {
font-size: 15px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-workspaces-toolbar-actions {
display: flex;
align-items: center;
gap: 4px;
}
.settings-v2-workspaces-delete-all {
color: var(--v2-state-fg-danger);
}
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
}
.settings-v2-workspaces-row {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
}
.settings-v2-workspaces-row:not(:last-child) {
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-row-header {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.settings-v2-workspaces-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 8px;
}
.settings-v2-workspaces-main {
display: flex;
min-width: 0;
}
.settings-v2-workspaces-row-actions {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
min-width: 0;
}
.settings-v2-workspaces-path {
display: block;
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-family: inherit;
font-size: 13px;
font-weight: 530;
line-height: 1;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
text-align: left;
cursor: default;
}
.settings-v2-workspaces-meta {
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
}
.settings-v2-workspaces-active,
.settings-v2-workspaces-more {
flex-shrink: 0;
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
}
.settings-v2-workspaces-sessions {
display: flex;
flex-direction: column;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 4px;
background-color: var(--v2-background-bg-base);
overflow: hidden;
}
.settings-v2-workspaces-session {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
font-size: 13px;
font-weight: 440;
line-height: 16px;
color: var(--v2-text-text-base);
}
.settings-v2-workspaces-session:not(:last-child) {
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-session > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-v2-workspaces-session-time {
flex-shrink: 0;
font-size: 11px;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-workspaces-empty {
display: flex;
align-items: center;
justify-content: center;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
@media (max-width: 639px) {
.settings-v2-workspaces-header {
padding: 24px 20px 20px;
}
.settings-v2-tab-body.settings-v2-workspaces {
padding: 0 20px 24px;
}
.settings-v2-workspaces-toolbar,
.settings-v2-workspaces-main {
align-items: flex-start;
}
.settings-v2-workspaces-toolbar {
flex-wrap: wrap;
}
.settings-v2-workspaces-toolbar-actions {
width: 100%;
flex-wrap: wrap;
justify-content: space-between;
}
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
padding: 14px;
}
.settings-v2-workspaces-path {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
.settings-v2-workspaces-active {
display: none;
}
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
width: 480px;
max-width: calc(100vw - 32px);
@@ -0,0 +1,502 @@
import type { Component } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import { createStore, produce } from "solid-js/store"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useQuery } from "@tanstack/solid-query"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { showToast } from "@/utils/toast"
import { getRelativeTime } from "@/utils/time"
import { pathKey } from "@/utils/path-key"
import { SettingsListV2 } from "./parts/list"
import { InlineServerSelect } from "./parts/server-select"
import { useTabs } from "@/context/tabs"
import { usePlatform } from "@/context/platform"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { ServerConnection } from "@/context/server"
import type { Project } from "@/types"
import {
containsDirectory,
filterWorkspaceInventory,
inspectWorkspaceDeletion,
managedWorkspaceDirectories,
mergeWorkspaceSessionInventory,
removeWorkspacesSequentially,
sessionsForWorkspace,
type WorkspaceDeleteInspection,
workspaceInventory,
} from "@/utils/workspace"
import { listAllSessions } from "@/utils/session"
import type { ServerScope } from "@/utils/server-scope"
import "./settings-v2.css"
type Workspace = {
directory: string
project: Project
}
export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const tabs = useTabs()
const platform = usePlatform()
const [store, setStore] = createStore({
project: "all",
transaction: undefined as "confirm" | "running" | undefined,
})
const workspaces = createMemo(() => workspaceInventory(serverSync().data.project))
const projects = createMemo(() =>
serverSync().data.project.filter((project) => managedWorkspaceDirectories(project).length > 0),
)
const projectName = (project: Project) => project.name || getFilename(project.worktree)
const projectOptions = createMemo(() => [
{ id: "all", label: language.t("settings.workspaces.filter.all") },
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
])
const selectedProject = createMemo(() =>
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
)
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
const captureDeleteContext = () => {
const sdk = serverSDK()
return { sdk, sync: serverSync(), server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
}
const loadSessions = async (context = captureDeleteContext()) => {
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
return mergeWorkspaceSessionInventory(
fetched,
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
)
}
const sessionQuery = useQuery(() => ({
queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const,
queryFn: () => loadSessions(),
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(
() =>
new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionQuery.isSuccess ? sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory) : [],
]),
),
)
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
const sessionCount = (workspace: Workspace) => {
if (sessionQuery.isPending) return language.t("session.messages.loading")
if (sessionQuery.isError) return language.t("common.requestFailed")
const count = workspaceSessions(workspace).length
return language.plural("settings.workspaces.sessions", count, {
count,
project: projectName(workspace.project),
})
}
const lastActive = (workspace: Workspace) => {
const updated = workspaceSessions(workspace)[0]?.time.updated
if (!updated) return undefined
return getRelativeTime(new Date(updated).toISOString(), language.t)
}
const sessionTime = (session: SessionInfo) => {
if (!session.time.updated) return undefined
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
}
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
const [working, branch, sessions] = await Promise.all([
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
loadSessions(context),
])
const result = inspectWorkspaceDeletion({
workspace: workspace.directory,
activeDirectory: context.activeDirectory,
sessions,
status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
})
return { result, sessions }
}
const inspectionMessages = (result: WorkspaceDeleteInspection) => {
const messages = [
result.active ? language.t("settings.workspaces.delete.blocked.active") : undefined,
result.linked ? language.t("settings.workspaces.delete.blocked.linked") : undefined,
result.dirty ? language.t("workspace.status.dirty") : undefined,
].filter((message): message is string => message !== undefined)
return messages.length > 0 ? messages : [language.t("workspace.status.clean")]
}
const blocked = (result: WorkspaceDeleteInspection) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: inspectionMessages(result)[0],
})
}
const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
const preflight = await inspect(workspace, context)
if (preflight.result.active || (!force && (preflight.result.linked || preflight.result.dirty))) {
blocked(preflight.result)
return
}
const removed = await context.sdk.api.worktree
.remove({
projectID: workspace.project.id,
directory: workspace.directory,
force,
})
.then(() => true)
.catch((error) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
if (!removed) return
tabs.store.forEach((tab) => {
if (tab.type !== "draft" || tab.server !== context.server) return
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
if (!directoryMatches && !worktreeMatches) return
tabs.updateDraft(tab.draftID, {
directory: directoryMatches ? workspace.project.worktree : tab.directory,
worktree: undefined,
})
})
clearWorkspaceTerminals(
workspace.directory,
preflight.sessions.map((session) => session.id),
platform,
context.sdk.scope,
)
context.sync.set(
"project",
produce((draft) => {
const project = draft.find((item) => item.id === workspace.project.id)
if (!project) return
project.sandboxes = (project.sandboxes ?? []).filter(
(directory) => pathKey(directory) !== pathKey(workspace.directory),
)
project.worktrees = project.worktrees.filter(
(worktree) => pathKey(worktree.directory) !== pathKey(workspace.directory),
)
}),
)
}
let inspectionID = 0
const releaseConfirmation = () => {
if (store.transaction === "confirm") setStore("transaction", undefined)
}
const transact = async (task: () => Promise<void>) => {
if (store.transaction !== "confirm") return
setStore("transaction", "running")
try {
await task()
} catch (error) {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
} finally {
setStore("transaction", undefined)
}
}
const confirmDelete = (workspace: Workspace) => {
if (store.transaction) return
const context = captureDeleteContext()
const current = ++inspectionID
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteWorkspace
workspace={workspace}
scope={context.sdk.scope}
inspectionID={current}
inspect={() => inspect(workspace, context)}
inspectionMessages={inspectionMessages}
onDelete={() => transact(() => remove(workspace, true, context))}
/>
),
releaseConfirmation,
)
}
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
}
const confirmDeleteAll = () => {
if (store.transaction) return
const context = captureDeleteContext()
const inventory = [...filtered()]
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteAllWorkspaces
count={inventory.length}
project={project}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
releaseConfirmation,
)
}
return (
<>
<div class="settings-v2-tab-header settings-v2-workspaces-header">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
<InlineServerSelect />
</div>
</div>
<div class="settings-v2-tab-body settings-v2-workspaces">
<div class="settings-v2-workspaces-toolbar">
<span class="settings-v2-workspaces-count">
{language.plural("settings.workspaces.count", filtered().length)}
</span>
<div class="settings-v2-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<MenuV2 placement="bottom-end" gutter={6}>
<MenuV2.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium 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">
<span class="min-w-0 truncate">
{projectOptions().find((option) => option.id === selectedProject())?.label}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<For each={projectOptions()}>
{(option) => (
<MenuV2.Item onSelect={() => setStore("project", option.id)}>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<Show when={selectedProject() === option.id}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
<Show when={filtered().length > 0}>
<MenuV2 placement="bottom-end" gutter={4}>
<MenuV2.Trigger
as={IconButtonV2}
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("common.moreOptions")}
disabled={!!store.transaction}
icon={<Icon name="outline-dots" size="small" />}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={confirmDeleteAll}>
<span class="settings-v2-workspaces-delete-all">
{language.t("settings.workspaces.deleteAll")}
</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
</div>
</div>
<div class="settings-v2-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
>
<SettingsListV2>
<For each={filtered()}>
{(workspace) => {
const linked = () => workspaceSessions(workspace)
return (
<div class="settings-v2-workspaces-row">
<div class="settings-v2-workspaces-row-header">
<div class="settings-v2-workspaces-copy">
<div class="settings-v2-workspaces-main">
<TooltipV2
value={workspace.directory}
placement="top-start"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
tabIndex={0}
dir="ltr"
aria-label={workspace.directory}
class="settings-v2-workspaces-path"
>
{workspace.directory}
</span>
</TooltipV2>
</div>
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
</div>
<div class="settings-v2-workspaces-row-actions">
<Show when={lastActive(workspace)}>
{(value) => (
<TooltipV2
value={language.t("settings.workspaces.lastActiveSession")}
placement="top-end"
>
<span tabIndex={0} class="settings-v2-workspaces-active">
{value()}
</span>
</TooltipV2>
)}
</Show>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace.directory),
})}
disabled={!!store.transaction}
icon={<Icon name="trash" size="small" />}
onClick={() => confirmDelete(workspace)}
/>
</div>
</div>
<Show when={linked().length > 0}>
<div class="settings-v2-workspaces-sessions">
<For each={linked()}>
{(session) => (
<div class="settings-v2-workspaces-session">
<span>{session.title}</span>
<Show when={sessionTime(session)}>
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
)
}}
</For>
</SettingsListV2>
</Show>
</div>
</div>
</>
)
}
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
const dialog = useDialog()
const language = useLanguage()
const remove = () => {
const deleting = props.onDelete()
dialog.close()
void deleting
}
return (
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("settings.workspaces.deleteAll")}
description={
<>
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
<br />
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
</>
}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="button" variant="danger" onClick={remove}>
{language.t("settings.workspaces.deleteAll")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
function DialogDeleteWorkspace(props: {
workspace: Workspace
scope: ServerScope
inspectionID: number
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: SessionInfo[] }>
inspectionMessages: (result: WorkspaceDeleteInspection) => string[]
onDelete: () => Promise<void>
}) {
const dialog = useDialog()
const language = useLanguage()
const status = useQuery(() => ({
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
queryFn: props.inspect,
staleTime: 0,
}))
const descriptions = () => {
if (status.isPending) return [language.t("workspace.status.checking")]
if (status.isError) return [language.t("workspace.status.error")]
if (!status.data) return []
return props.inspectionMessages(status.data.result)
}
const remove = () => {
const deleting = props.onDelete()
dialog.close()
void deleting
}
return (
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("workspace.delete.title")}
description={
<>
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
<br />
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
{props.workspace.directory}
</code>
<br />
{language.t("settings.workspaces.delete.warning")}
<For each={descriptions()}>{(description) => <div>{description}</div>}</For>
</>
}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
type="button"
variant="danger"
disabled={status.isPending || status.isError || status.data?.result.active}
onClick={remove}
>
{language.t("workspace.delete.button")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
@@ -14,6 +14,7 @@ import { ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server"
type ProjectApi = ServerApi["project"]
type WorktreeApi = ServerApi["worktree"]
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
@@ -106,16 +107,58 @@ describe("query keys", () => {
})
test("loads projects from the current endpoint", async () => {
const api = {
const calls: string[] = []
const projects = {
list: async () => [
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
],
} as unknown as ProjectApi
const worktrees = {
list: async ({ projectID }: { projectID: string }) => {
calls.push(projectID)
return [
{ directory: `/${projectID}` },
{ directory: `/${projectID}/clone` },
{ directory: `/${projectID}/copy`, strategy: "git" },
]
},
} as unknown as WorktreeApi
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
expect(result.map((project) => project.id)).toEqual(["a", "b"])
expect(result.map((project) => project.sandboxes)).toEqual([
["/a/clone", "/a/copy"],
["/b/clone", "/b/copy"],
])
expect(result.map((project) => project.worktrees)).toEqual([
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
])
expect(calls.toSorted()).toEqual(["a", "b"])
})
test("keeps projects whose directory inventory cannot load", async () => {
const projects = {
list: async () => [
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
],
} as unknown as ProjectApi
const worktrees = {
list: async ({ projectID }: { projectID: string }) => {
if (projectID === "b") throw new Error("unavailable")
return [{ directory: "/a/copy", strategy: "git" as const }]
},
} as unknown as WorktreeApi
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
{ id: "a", sandboxes: ["/a/copy"] },
{ id: "b", sandboxes: [] },
])
})
test("loads references from the current location-scoped endpoint", async () => {
@@ -35,6 +35,7 @@ import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server"
import { sameDirectory } from "@/utils/workspace"
type GlobalStore = {
ready: boolean
@@ -106,6 +107,7 @@ type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type WorktreeApi = Pick<ServerApi["worktree"], "list">
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
type McpApi = ServerApi["mcp"]
@@ -113,15 +115,35 @@ type PermissionApi = ServerApi["permission"]
type QuestionApi = ServerApi["question"]
type VcsApi = ServerApi["vcs"]
export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
queryOptions({
queryKey: [scope, "project"],
queryFn: () =>
retry(() =>
api.list().then((projects) => {
return projects
.filter((p) => !!p?.id)
.map(normalizeProjectInfo)
projects.list().then(async (items) => {
return (
await Promise.all(
items
.filter((project) => !!project?.id)
.map(async (project) => {
const directories = await worktrees
.list({ projectID: project.id })
.catch(() => [
{ directory: project.canonical },
...(project.sandboxes ?? [])
.filter((directory) => !sameDirectory(project.canonical, directory))
.map((directory) => ({ directory })),
])
return normalizeProjectInfo({
...project,
sandboxes: directories
.map((item) => item.directory)
.filter((directory) => !sameDirectory(project.canonical, directory)),
worktrees: directories,
})
}),
)
)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
@@ -130,7 +152,11 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
})
export async function bootstrapGlobal(input: {
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
serverAPI: CatalogApi & {
readonly location: LocationApi
readonly project: ProjectApi
readonly worktree: WorktreeApi
}
scope: ServerScope
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
@@ -144,7 +170,7 @@ export async function bootstrapGlobal(input: {
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
.then((data) => input.setGlobalStore("project", data)),
]
await runAll(slow)
@@ -124,9 +124,11 @@ export function normalizeProviderList(
}
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
const worktree = "canonical" in project ? project.canonical : project.worktree
return {
...project,
worktree: "canonical" in project ? project.canonical : project.worktree,
worktree,
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
vcs: project.vcs === "git" ? "git" : undefined,
}
}
+30 -13
View File
@@ -2,7 +2,12 @@ import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
import {
pluralCategory,
type UiI18nPluralLookupKey,
type UiI18nPluralKey,
type UiPluralCategory,
} from "@opencode-ai/ui/context/i18n"
import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
@@ -28,13 +33,17 @@ function localeDirection(locale: Locale): Direction {
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary>
type PluralKey =
| UiI18nPluralKey
| "session.question.pending"
| "session.followupDock.summary"
| "session.revertDock.summary"
| "session.background.shell"
| "session.background.subagent"
type AppI18nKey = Extract<keyof typeof en, string>
type AppI18nPluralKey = {
[Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never
}[AppI18nKey]
type PluralKey = AppI18nPluralKey | UiI18nPluralKey
type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}`
type TranslationKey<Key extends Extract<keyof Dictionary, string>> = Key extends
| AppI18nPluralLookupKey
| UiI18nPluralLookupKey
? never
: Key
type Source = { dict: Record<string, string> }
function cookie(locale: Locale) {
@@ -191,18 +200,25 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
initialValue: dicts.get(initial) ?? base,
})
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
key: keyof Dictionary,
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as <
Key extends Extract<keyof Dictionary, string>,
>(
key: TranslationKey<Key>,
params?: Record<string, string | number | boolean>,
) => string
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
const category = pluralCategory(intl(), count)
const pluralForm = (
key: PluralKey,
category: UiPluralCategory,
params?: Record<string, string | number | boolean>,
) => {
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
const candidate = `${key}.${category}`
const fallback = `${key}.other`
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, params)
}
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) =>
pluralForm(key, pluralCategory(intl(), count), { ...params, count })
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value]
@@ -233,6 +249,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
label,
t,
plural,
pluralForm,
setLocale(next: Locale) {
setStore("locale", normalizeLocale(next))
},
+12 -7
View File
@@ -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()),
@@ -541,6 +541,23 @@ describe("server session", () => {
expect(ctx.store.lineage.peek("child")).toEqual(result)
})
test("applies moved session locations without evicting cached state", () => {
const current = { ...session("child"), location: { directory: "/repo/worktree" } }
const ctx = setup({ child: current })
ctx.store.remember(current)
ctx.store.applyV2({
id: "evt_moved",
created: 2,
type: "session.moved",
durable: { aggregateID: "child", seq: 1, version: 1 },
location: current.location,
data: { sessionID: "child", location: { directory: "/repo" }, projectID: "project", subpath: "packages/app" },
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>)
expect(ctx.store.get("child")).toMatchObject({ location: { directory: "/repo" }, subpath: "packages/app" })
})
test("loads session content through the server client", async () => {
const ctx = setup({ root: session("root") })
@@ -1184,6 +1201,16 @@ describe("server session", () => {
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
})
test("removes projected messages when rolling back optimistic content", () => {
const message = userMessage("message")
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [] })
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.session_message.child).toEqual([])
})
test("does not remove content confirmed by a message event", () => {
const message = userMessage("message")
const part = textPart(message.id)
+12 -1
View File
@@ -257,7 +257,13 @@ export function createServerSession(
const indexProjectedMessage = (message: Message) => {
const current = data.session_message[message.sessionID] ?? []
if (current.some((item) => item.id === message.id)) return
setData("session_message", message.sessionID, reconcile([...current, ...projectMessageSource(message)]))
const projected = projectMessageSource(message)
const projectedIDs = new Set(projected.map((item) => item.id))
setData(
"session_message",
message.sessionID,
reconcile([...current.filter((item) => !projectedIDs.has(item.id)), ...projected]),
)
}
const remember = (session: SessionInfo) => {
@@ -1443,6 +1449,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: [] }]]))
indexProjectedMessage(input.message)
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
setData(
"part_text_accum_delta",
@@ -1476,6 +1483,10 @@ export function createServerSession(
)
return
}
const projectedIDs = new Set(projectMessageSource(item.message).map((message) => message.id))
setData("session_message", input.sessionID, (messages) =>
messages?.filter((message) => !projectedIDs.has(message.id)),
)
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
},
@@ -15,6 +15,7 @@ import {
loadMcpResourcesQuery,
reconcileActiveSessionStatuses,
seedActiveSessionStatuses,
shouldRefreshWorkspaceSessions,
} from "./server-sync"
import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session"
@@ -202,6 +203,19 @@ describe("estimateRootSessionTotal", () => {
})
})
describe("workspace session inventory", () => {
test("refreshes for session identity and location changes", () => {
const event = (type: string, current?: string) =>
({ type, current: current ? { type: current } : undefined }) as Parameters<
typeof shouldRefreshWorkspaceSessions
>[0]
expect(shouldRefreshWorkspaceSessions(event("session.created"))).toBe(true)
expect(shouldRefreshWorkspaceSessions(event("session.updated", "session.moved"))).toBe(true)
expect(shouldRefreshWorkspaceSessions(event("message.updated"))).toBe(false)
})
})
describe("canDisposeDirectory", () => {
test("rejects pinned or inflight directories", () => {
expect(
+23 -11
View File
@@ -58,6 +58,17 @@ import { createCatalogSync } from "./server-sync/catalog"
import { createConnectionSync } from "./server-sync/connection"
import { usePlatform } from "./platform"
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
const type = event.current?.type ?? event.type
return (
type === "session.created" ||
type === "session.deleted" ||
type === "session.moved" ||
type === "session.renamed" ||
type === "session.forked"
)
}
type GlobalStore = {
ready: boolean
error?: InitError
@@ -185,7 +196,7 @@ export function reconcileActiveSessionStatuses(
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
return {
globalConfig: () => loadGlobalConfigQuery(scope),
projects: () => loadProjectsQuery(scope, serverAPI.project),
projects: () => loadProjectsQuery(scope, serverAPI.project, serverAPI.worktree),
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
@@ -553,12 +564,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
const toDirectoryEvent = (event: ServerEvent) => {
if (event.current?.type === "session.created") return
if (
event.current?.type !== "session.renamed" &&
event.current?.type !== "session.moved" &&
event.current?.type !== "session.usage.updated"
)
return event
if (event.current?.type !== "session.renamed" && event.current?.type !== "session.usage.updated") return event
const info = session.get(event.current.data.sessionID)
if (info) return { type: "session.updated", properties: { info } }
return event
@@ -576,6 +582,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
markSessionListChanged(event, directory, previousDirectory)
if (event.current) session.applyV2(event.current)
session.apply(event)
if (event.current?.type === "session.moved") {
const info = session.get(event.current.data.sessionID)
if (info) indexSession(info)
}
if (shouldRefreshWorkspaceSessions(event)) {
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions",
})
}
if (event.current?.type === "session.created")
void session
.resolve(event.current.data.sessionID, { force: true })
@@ -630,10 +646,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 })
+35
View File
@@ -2,6 +2,10 @@ import { createStore, reconcile } from "solid-js/store"
import { createEffect, createMemo } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
export type WorkspaceLastUsed = "local" | "workspace"
export interface NotificationSettings {
agent: boolean
@@ -44,6 +48,10 @@ export interface Settings {
permissions: {
autoApprove: boolean
}
workspaces: {
defaultDestination: WorkspaceDefaultDestination
lastUsed: Record<string, WorkspaceLastUsed>
}
notifications: NotificationSettings
sounds: SoundSettings
}
@@ -126,6 +134,10 @@ const defaultSettings: Settings = {
permissions: {
autoApprove: false,
},
workspaces: {
defaultDestination: "last-used",
lastUsed: {},
},
notifications: {
agent: true,
permissions: true,
@@ -291,6 +303,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("permissions", "autoApprove", value)
},
},
workspaces: {
defaultDestination: withFallback(
() => store.workspaces?.defaultDestination,
defaultSettings.workspaces.defaultDestination,
),
setDefaultDestination(value: WorkspaceDefaultDestination) {
setStore("workspaces", (current) => ({
...defaultSettings.workspaces,
...current,
defaultDestination: value,
}))
},
lastUsed(scope: ServerScope, projectID: string) {
return store.workspaces?.lastUsed?.[ScopedKey.from(scope, projectID)]
},
setLastUsed(scope: ServerScope, projectID: string, value: WorkspaceLastUsed) {
setStore("workspaces", (current) => ({
...defaultSettings.workspaces,
...current,
lastUsed: { ...current?.lastUsed, [ScopedKey.from(scope, projectID)]: value },
}))
},
},
notifications: {
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
setAgent(value: boolean) {
+41
View File
@@ -1166,6 +1166,47 @@ export const dict = {
"session.delete.button": "Delete session",
"workspace.new": "New workspace",
"common.viewAll": "View all",
"session.new.workspace.local.tooltip": "Use current checkout",
"session.new.workspace.new.tooltip": "Create isolated checkout",
"session.new.workspace.fromBranch": "from {{branch}}",
"session.new.workspace.trigger.tooltip": "Select where to run session",
"session.new.workspace.search.placeholder": "Search workspaces",
"settings.tab.workspaces": "Workspaces",
"settings.workspaces.filter.all": "All projects",
"settings.workspaces.empty": "No workspaces",
"settings.workspaces.count.one": "{{count}} workspace",
"settings.workspaces.count.other": "{{count}} workspaces",
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
"settings.workspaces.lastActiveSession": "Last active session",
"settings.workspaces.deleteAll": "Delete all workspaces",
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
"settings.workspaces.delete.warning":
"The workspace directory and branch will be permanently removed, including any unmerged changes shown below.",
"settings.workspaces.deleteAll.warning":
"The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
"settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
"settings.workspaces.delete.blocked.linked":
"Linked sessions will remain, but their working directory will be permanently removed.",
"settings.workspaces.default.title": "Default environment",
"settings.workspaces.default.description": "Choose where new sessions start",
"settings.workspaces.default.lastUsed": "Last used per project",
"settings.workspaces.default.local": "Local directory",
"settings.workspaces.default.new": "New workspace",
"workspace.move.title": "Move to workspace",
"workspace.move.menu.title": "Move session to",
"workspace.move.failed": "Failed to move session",
"workspace.lifecycle.creating": "Creating workspace",
"workspace.lifecycle.created": "Workspace created",
"workspace.lifecycle.starting": "Starting session",
"workspace.onboarding.title": "Isolate sessions with workspaces",
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
"workspace.lifecycle.moving": "Moving to workspace",
"workspace.lifecycle.set": "Workspace set",
"session.summary.title": "Session details",
"session.summary.noBranch": "No branch",
"session.summary.basedOn": "Based on {{branch}}",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Failed to create workspace",
+5
View File
@@ -327,4 +327,9 @@
animation-range: 0 0.1px;
}
}
body[data-new-layout] [data-slot="session-turn-diffs-header"] {
height: 24px;
padding-block: 0;
}
}
+18 -2
View File
@@ -1,7 +1,10 @@
import { createPromptProjectController } from "@/components/prompt-project-selector"
import { useSettingsDialog } from "@/components/settings-dialog"
import { useTitlebarRightMount } from "@/components/titlebar"
import { useSettings } from "@/context/settings"
import { createEffect, createResource } from "solid-js"
import { useTabs, type DraftTab } from "@/context/tabs"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource } from "solid-js"
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
@@ -11,10 +14,23 @@ import { useNewSessionCommands } from "./new-session/use-new-session-commands"
export default function NewSessionPage() {
const settings = useSettings()
const rightMount = useTitlebarRightMount()
const workspace = createNewSessionWorkspaceController()
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
const openWorkspaces = useSettingsDialog("workspaces")
const draftTab = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const workspace = createNewSessionWorkspaceController({
selected: () => draftTab()?.worktree,
setSelected: (worktree) => {
if (search.draftId) tabs.updateDraft(search.draftId, { worktree })
},
onViewAll: openWorkspaces,
})
const draft = createNewSessionDraftController({
worktree: workspace.selection.value,
resetWorktree: workspace.selection.reset,
onSubmit: workspace.selection.remember,
})
const project = createPromptProjectController({
controls: draft.project.controls,
@@ -10,7 +10,11 @@ import { createPromptModelSelection } from "@/pages/session/composer/prompt-mode
import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) {
export function createNewSessionDraftController(workspace: {
worktree: () => string
resetWorktree: () => void
onSubmit: () => void
}) {
const prompt = usePrompt()
const serverSync = useServerSync()
const comments = useComments()
@@ -36,7 +40,10 @@ export function createNewSessionDraftController(workspace: { worktree: () => str
return workspace.worktree()
},
onNewSessionWorktreeReset: workspace.resetWorktree,
onSubmit: comments.clear,
onSubmit: () => {
workspace.onSubmit()
comments.clear()
},
})
createEffect(() => {
@@ -1,6 +1,6 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
import { Show, createMemo, createSignal } from "solid-js"
@@ -31,6 +31,15 @@ export function NewSessionView(props: {
project: PromptProjectController
workspace: NewSessionWorkspaceController
}) {
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
createStore({ used: false }),
)
const select = (value: string) => {
props.workspace.selection.set(value)
if (value !== "main") setOnboarding("used", true)
}
return (
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div
@@ -41,7 +50,7 @@ export function NewSessionView(props: {
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8 flex flex-col gap-8">
<PromptInputV2Composer controller={props.input} />
<PromptInputV2Composer controller={props.input} accentSubmit={props.workspace.selection.workspace()} />
<Show when={props.project.empty()}>
<PromptProjectAddButton controller={props.project} />
</Show>
@@ -59,8 +68,10 @@ export function NewSessionView(props: {
projectRoot={props.workspace.project.root()}
workspaces={props.workspace.project.workspaces()}
branch={props.workspace.bar.branch()}
onChange={props.workspace.selection.set}
onboarding={onboardingReady() && !onboarding.used}
onChange={select}
onDone={props.input.restoreFocus}
onViewAll={props.workspace.project.openAll}
/>
</Show>
</div>
@@ -137,7 +148,7 @@ function ProviderTip() {
>
<span class="truncate">{language.t("home.providerTip")}</span>
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
<Icon name="chevron-down" size="small" class="-rotate-90" />
</span>
</button>
<TooltipV2
@@ -152,7 +163,7 @@ function ProviderTip() {
aria-label={language.t("common.dismiss")}
onClick={() => setPersistedState("dismissedAt", Date.now())}
>
<IconV2 name="xmark-small" />
<Icon name="xmark-small" />
</button>
</TooltipV2>
</div>
@@ -31,6 +31,13 @@ describe("new session workspace selection", () => {
expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main")
})
test("treats equivalent Windows roots as the main worktree", () => {
expect(resolveNewSessionWorktree({ enabled: true, directory: "C:\\Repo\\", projectWorktree: "c:/repo" })).toBe(
"main",
)
expect(normalizeNewSessionWorktree("main", "C:\\Repo\\", "c:/repo")).toBe("main")
})
test("falls back to the local branch for main, create, and unknown worktrees", () => {
const branch = (worktree: string) => (worktree === "/project/feature" ? "feature" : undefined)
expect(resolveNewSessionBranch({ worktree: "main", local: "dev", worktreeBranch: branch })).toBe("dev")
@@ -1,24 +1,32 @@
import { createMemo, createSignal } from "solid-js"
import { createMemo } from "solid-js"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
import {
isWorkspaceDirectory,
isWorkspaceSelection,
sameDirectory,
workspaceDefaultSelection,
workspaceDirectories,
} from "@/utils/workspace"
export function resolveNewSessionWorktree(input: {
enabled: boolean
selected?: string
directory: string
projectWorktree?: string
fallback?: string
}) {
if (!input.enabled) return "main"
if (input.selected) return input.selected
if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory
return "main"
if (input.projectWorktree && !sameDirectory(input.directory, input.projectWorktree)) return input.directory
return input.fallback ?? "main"
}
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
if (value === "main" && projectWorktree !== directory) return projectWorktree
if (value === "main" && projectWorktree && !sameDirectory(directory, projectWorktree)) return projectWorktree
return value
}
@@ -31,18 +39,38 @@ export function resolveNewSessionBranch(input: {
return input.worktreeBranch(input.worktree) ?? input.local
}
export function createNewSessionWorkspaceController() {
export function createNewSessionWorkspaceController(input: {
selected: () => string | undefined
setSelected: (worktree: string | undefined) => void
onViewAll: () => void
}) {
const sdk = useSDK()
const sync = useSync()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const [worktree, setWorktree] = createSignal<string>()
const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
const settings = useSettings()
const visible = createMemo(() => sync().project?.vcs === "git")
const selected = createMemo(() => {
const project = sync().project
const worktree = input.selected()
if (!project || !worktree) return
return isWorkspaceSelection(project, worktree) ? worktree : undefined
})
const fallback = createMemo(() => {
const project = sync().project
if (!project) return "main"
return workspaceDefaultSelection(
settings.workspaces.defaultDestination(),
settings.workspaces.lastUsed(serverSDK().scope, project.id),
)
})
const value = createMemo(() =>
resolveNewSessionWorktree({
enabled: visible(),
selected: worktree(),
selected: selected(),
directory: sdk().directory,
projectWorktree: sync().project?.worktree,
fallback: fallback(),
}),
)
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
@@ -54,18 +82,36 @@ export function createNewSessionWorkspaceController() {
worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
}),
)
const remember = (worktree = value()) => {
const project = sync().project
if (!project) return
const local = worktree === "main" || sameDirectory(worktree, project.worktree)
settings.workspaces.setLastUsed(serverSDK().scope, project.id, local ? "local" : "workspace")
}
return {
selection: {
value,
reset: () => setWorktree(),
set: (worktree: string) =>
setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)),
workspace: createMemo(() => {
const project = sync().project
const current = value()
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
}),
reset: () => input.setSelected(undefined),
remember,
set: (worktree: string) => {
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree))
remember(worktree)
},
},
project: {
root: projectRoot,
workspaces: () => sync().project?.sandboxes ?? [],
workspaces: () => {
const project = sync().project
return project ? workspaceDirectories(project) : []
},
git: () => sync().project?.vcs === "git",
openAll: input.onViewAll,
},
bar: {
visible,
+37 -3
View File
@@ -38,6 +38,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
import { isWorkspaceDirectory } from "@/utils/workspace"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
@@ -518,6 +519,8 @@ export default function Page() {
if (!controller.layout.view().reviewPanel.opened()) controller.layout.view().reviewPanel.open()
}
const sessionDirectory = createMemo(() => controller.data.info()?.location.directory ?? sdk().directory)
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
const timeline = createTimelineModel({ session: controller })
const historyLoading = timeline.history.loading
const historyMore = timeline.history.more
@@ -572,6 +575,7 @@ export default function Page() {
const [store, setStore] = createStore({
...sessionViewState(),
newSessionWorktree: "main",
sessionDetailsOpen: false,
deferRender: false,
})
@@ -676,8 +680,23 @@ export default function Page() {
: skipToken,
}
})
const sessionDetailsQuery = createQuery(() => ({
queryKey: [serverSDK().scope, "session-details", sessionDirectory()] as const,
enabled:
store.sessionDetailsOpen && serverSDK().connection.status() === "connected" && sync().project?.vcs === "git",
queryFn: () =>
sdk()
.api.vcs.diff({ location: { directory: sessionDirectory() }, mode: "working" })
.then((result) => result.data)
.catch((error) => {
console.debug("[session-review] failed to load session details diff", { error })
return []
}),
}))
const sessionDetailsDiffs = () => (sessionDetailsQuery.isFetched ? (sessionDetailsQuery.data ?? []) : undefined)
const refreshVcs = debounce(() => {
void queryClient.invalidateQueries({ queryKey: vcsKey() })
void queryClient.invalidateQueries({ queryKey: [serverSDK().scope, "session-details", sessionDirectory()] })
}, 100)
onCleanup(
sdk().event.listen((event) => {
@@ -1687,7 +1706,6 @@ export default function Page() {
}
const busy = (sessionID: string) => sync().data.session_working(sessionID)
const queuedFollowups = createMemo(() => {
const id = controller.identity.params.id
if (!id) return emptyFollowups
@@ -1700,6 +1718,12 @@ export default function Page() {
return followup.edit[id]
})
const workspaceMoveEligible = createMemo(() => {
const id = controller.identity.params.id
if (!id) return false
return (followup.items[id]?.length ?? 0) === 0 && !followup.failed[id] && !followup.paused[id] && !followup.edit[id]
})
const followupMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
const owner = controller.ownership.capture()
@@ -2036,7 +2060,7 @@ export default function Page() {
>
{hasReview()
? language.t("session.review.filesChanged", { count: reviewCount() })
: language.t("session.review.change.other")}
: language.plural("session.review.change", 0)}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
@@ -2102,6 +2126,10 @@ export default function Page() {
if (root) scheduleScrollState(root)
}}
userMessages={visibleUserMessages()}
diffs={sessionDetailsDiffs}
onReview={openReviewPanel}
workspaceMoveEligible={workspaceMoveEligible()}
onSummaryOpenChange={(open) => setStore("sessionDetailsOpen", open)}
setHistoryAnchor={(handlers) => {
captureHistoryAnchor = handlers.capture
restoreHistoryAnchor = handlers.restore
@@ -2229,7 +2257,13 @@ export default function Page() {
setFollowup("paused", id, true)
},
})
return <PromptInputV2Composer controller={promptInputController} borderUnderlay />
return (
<PromptInputV2Composer
controller={promptInputController}
borderUnderlay
accentSubmit={workspaceSession()}
/>
)
}}
</Show>
}
@@ -86,7 +86,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
}
@@ -782,10 +782,7 @@ export function SessionSidePanel(props: {
when={settings.general.newLayoutDesigns()}
fallback={
<>
{props.reviewCount}{" "}
{language.t(
props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
)}
{props.reviewCount} {language.plural("session.review.change", props.reviewCount)}
</>
}
>
@@ -33,6 +33,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 { InlineInput } from "@opencode-ai/ui/inline-input"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
@@ -41,7 +43,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import type { AssistantMessage, ToolPart, UserMessage } from "@/types"
import type { AssistantMessage, Project, ToolPart, UserMessage } from "@/types"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { normalize } from "@opencode-ai/session-ui/session-diff"
@@ -49,11 +51,18 @@ import { useFileComponent } from "@opencode-ai/ui/context/file"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useLanguage } from "@/context/language"
import { useServerSync } from "@/context/server-sync"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/utils/workspace"
import { SessionWorkspaceMenu } from "@/components/session-workspace-menu"
import { getProjectAvatarVariant } from "@/context/layout"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
const emptyTools: ToolPart[] = []
@@ -109,7 +118,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({
@@ -137,6 +146,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
@@ -191,6 +201,165 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
)
}
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
eligible: boolean
sessionID: string
project: Project
directory: string
dismissed: boolean
onDismiss: () => void
}) {
const language = useLanguage()
const inline = () => props.variant === "inline"
return (
<div
classList={{
"group/workspace-move relative shrink-0": true,
"ms-auto h-5 w-[167px]": inline(),
"-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03 transition-colors":
!inline(),
hidden: props.dismissed,
}}
>
<SessionWorkspaceMenu
eligible={props.eligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={inline() ? "bottom-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
gutter={inline() ? 4 : -22}
contentClass={inline() ? undefined : "relative top-3.5"}
class={
inline()
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pe-6 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-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
}
>
<IconV2 name="workspace-new" 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={`absolute flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted 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 ${
inline()
? "end-0 top-1/2"
: "hover-reveal end-3 top-[calc(50%+5px)] group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100"
}`}
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
moveDismissed: boolean
onMoveDismiss: () => void
onReview: () => void
}) {
const language = useLanguage()
const location = () => {
if (props.local) return language.t("session.new.workspace.local")
const workspace = workspaceDirectories(props.project).find((item) => containsDirectory(item, props.directory))
return getFilename(workspace ?? 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]">
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<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 text-v2-text-text-muted">{displayName(props.project)}</span>
</div>
<SessionWorkspaceMenu
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={language.direction() === "rtl" ? "right-start" : "left-start"}
gutter={-22}
class={`${row} 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={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 flex-1 truncate text-start">{location()}</span>
<IconV2 name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
<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>{language.t("session.summary.noBranch")}</span>
<Show when={props.baseBranch}>
{(base) => (
<>
<span class="text-v2-text-text-muted">·</span>
<span class="truncate text-v2-text-text-faint">
{language.t("session.summary.basedOn", { branch: base() })}
</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} fallback={<span>{language.t("session.review.loadingChanges")}</span>}>
{(diffs) => (
<Show when={diffs().length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
<span>{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}</span>
<span class="text-v2-text-text-muted">·</span>
<DiffChanges changes={diffs()} />
</Show>
)}
</Show>
</button>
</div>
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
<WorkspaceMoveAction
variant="panel"
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
dismissed={props.moveDismissed}
onDismiss={props.onMoveDismiss}
/>
</Show>
</div>
)
}
function TimelineDiffView(props: { diff: SummaryDiff }) {
const fileComponent = useFileComponent()
const view = normalize(props.diff)
@@ -219,6 +388,10 @@ type MessageTimelineProps = {
centered: boolean
setContentRef: (el: HTMLDivElement) => void
userMessages: UserMessage[]
diffs: Accessor<{ additions: number; deletions: number }[] | undefined>
onReview: () => void
workspaceMoveEligible: boolean
onSummaryOpenChange: (open: boolean) => void
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
@@ -241,6 +414,9 @@ function MessageTimelineView(
) {
let touchGesture: number | undefined
const language = useLanguage()
const serverSync = useServerSync()
const sdk = useSDK()
const sync = useSync()
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
const hasScrollGesture = createMemo(() => props.hasScrollGesture)
const ownerSessionKey = props.data.sessionKey()
@@ -257,19 +433,35 @@ function MessageTimelineView(
const parentID = props.data.parentID
const parentTitle = props.data.parentTitle
const childTitle = props.data.childTitle
const showHeader = props.data.showHeader
const getMsgParts = props.data.parts
const getMsgPart = props.data.part
const projection = props.data.projection
const sessionDirectory = createMemo(() => props.session.data.info()?.location.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 = () => "px-4 md:px-5"
const showHeader = createMemo(() => props.data.showHeader() || workspaceSession())
const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent
const lastAssistantGroupKey = projection.lastAssistantGroupKey
const messageByID = projection.messageByID
const sessionMessageByID = projection.sessionMessageByID
const messageLastRowIndex = projection.messageLastRowIndex
const messageRowIndex = projection.messageRowIndex
const timelineRowByKey = projection.rowByKey
const timelineRows = projection.rows
const sessionMessageByID = projection.sessionMessageByID
const noticeContent = (message: SessionMessageInfo) => {
if (message.type === "agent-switched")
return {
@@ -788,7 +980,7 @@ function MessageTimelineView(
)
return (
<TimelineRowFrame row={commentStripRow()}>
<div class="w-full px-4 md:px-5 pb-2">
<div class={`w-full pb-2 ${turnPadding()}`}>
<div class="ms-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
<Index each={comments()}>
@@ -839,7 +1031,7 @@ function MessageTimelineView(
<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()}
@@ -865,7 +1057,7 @@ function MessageTimelineView(
<TimelineRowFrame row={noticeRow()}>
<Show when={content()}>
{(content) => (
<div data-slot="session-timeline-notice" class="w-full px-4 pt-3 pb-1 md:px-5 text-13-regular">
<div data-slot="session-timeline-notice" class={`w-full pt-3 pb-1 text-13-regular ${turnPadding()}`}>
<span class="text-13-medium text-text-strong">{content().label}</span>
<Show when={content().data}>{(data) => <span class="text-text-weak"> · {data()}</span>}</Show>
</div>
@@ -878,7 +1070,7 @@ function MessageTimelineView(
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(
@@ -894,7 +1086,7 @@ function MessageTimelineView(
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)}
@@ -909,7 +1101,7 @@ function MessageTimelineView(
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={props.data.showReasoningSummaries()}
@@ -922,7 +1114,7 @@ function MessageTimelineView(
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>
@@ -930,10 +1122,34 @@ function MessageTimelineView(
}
case "DiffSummary": {
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
const canMove = () =>
props.data.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()}
dismissed={workspaceSuggestionDismissed()}
onDismiss={() => setWorkspaceSuggestionDismissed(true)}
/>
)}
</Show>
}
/>
</div>
</TimelineRowFrame>
)
@@ -942,7 +1158,7 @@ function MessageTimelineView(
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>
@@ -1024,7 +1240,7 @@ function MessageTimelineView(
}
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={{
@@ -1119,6 +1335,30 @@ function MessageTimelineView(
}}
>
<div class="flex items-center min-w-0 flex-1 w-full">
<Show when={props.data.newLayoutDesigns()}>
<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-accent"
>
<IconV2 name="workspace-isolated" />
</span>
</TooltipV2>
</Show>
</Show>
<Show when={parentID()}>
<button
type="button"
@@ -1205,6 +1445,46 @@ function MessageTimelineView(
placement="bottom"
buttonAppearance={props.data.newLayoutDesigns() ? "v2" : "default"}
/>
<Show when={props.data.newLayoutDesigns() && !parentID() && sync().project}>
{(project) => (
<KobaltePopover
open={summaryOpen()}
placement="bottom-end"
gutter={6}
onOpenChange={setSummary}
>
<KobaltePopover.Trigger
as={IconButtonV2}
icon={<IconV2 name="window-analytics" />}
variant="ghost-muted"
size="large"
state={summaryOpen() ? "pressed" : undefined}
aria-label={language.t("session.summary.title")}
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}
moveDismissed={workspaceSuggestionDismissed()}
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
onReview={() => {
setSummary(false)
props.onReview()
}}
/>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
)}
</Show>
<Show when={!parentID()}>
<Show
when={props.data.newLayoutDesigns()}
@@ -13,10 +13,8 @@ import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { findLast } from "@opencode-ai/core/util/array"
import { extractPromptFromParts } from "@/utils/prompt"
import type { UserMessage } from "@/types"
import { useLocal } from "@/context/local"
import type { SessionController } from "./session-controller"
type SessionCommandSource = {
@@ -58,7 +56,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
const local = useLocal()
const navigate = useNavigate()
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
const owner = actions.session.ownership.capture()
@@ -364,6 +361,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const fork = () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
void openDialog(
() => import("@/components/dialog-fork"),
(x) => dialog.show(() => <x.DialogFork />),
+10 -2
View File
@@ -1,7 +1,15 @@
import type { EventSubscribeOutput, FileDiffInfo, ProjectListOutput } from "@opencode-ai/client/promise"
import type {
EventSubscribeOutput,
FileDiffInfo,
ProjectListOutput,
WorktreeDirectory,
} from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export type Project = Omit<ProjectListOutput[number], "canonical"> & { worktree: string }
export type Project = Omit<ProjectListOutput[number], "canonical"> & {
worktree: string
worktrees: WorktreeDirectory[]
}
type CurrentEvent = EventSubscribeOutput extends infer Item
? Item extends { type: infer Type extends string; data: infer Data }
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import {
filterWorkspaceInventory,
inspectWorkspaceDeletion,
isWorkspaceDirectory,
isWorkspaceSelection,
mergeWorkspaceSessionInventory,
sessionsForWorkspace,
workspaceInventory,
} 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)
expect(
isWorkspaceDirectory({ worktree: "/repo", sandboxes: ["/repo/.worktrees/feature"] }, "/repo/.worktrees/feature"),
).toBe(true)
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)
expect(isWorkspaceSelection({ worktree: "C:\\repo" }, "c:\\repo\\")).toBe(true)
expect(isWorkspaceSelection(project, "/other/workspace")).toBe(false)
})
})
test("groups and filters workspace inventory by project", () => {
const inventory = workspaceInventory([
{
id: "a",
worktree: "/a",
sandboxes: ["/a", "/a/one", "/a/two"],
worktrees: [
{ directory: "/a" },
{ directory: "/a/one", strategy: "git" },
{ directory: "/a/two", strategy: "git" },
],
},
{
id: "b",
worktree: "/b",
sandboxes: ["/b/one"],
worktrees: [{ directory: "/b/one", strategy: "git" }],
},
])
expect(inventory.map((item) => [item.project.id, item.directory])).toEqual([
["a", "/a/one"],
["a", "/a/two"],
["b", "/b/one"],
])
expect(filterWorkspaceInventory(inventory, "a").map((item) => item.directory)).toEqual(["/a/one", "/a/two"])
expect(filterWorkspaceInventory(inventory, "all")).toEqual(inventory)
})
test("reports every workspace deletion condition", () => {
const session = (directory: string) => ({ location: { directory }, time: { created: 1, updated: 1 } }) as SessionInfo
expect(
inspectWorkspaceDeletion({
workspace: "/workspace",
activeDirectory: "/workspace/app",
sessions: [],
status: "dirty",
}),
).toEqual({ active: true, linked: false, dirty: true })
expect(
inspectWorkspaceDeletion({
workspace: "/workspace",
sessions: [session("/workspace/packages/app")],
status: "dirty",
}),
).toEqual({ active: false, linked: true, dirty: true })
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "dirty" })).toEqual({
active: false,
linked: false,
dirty: true,
})
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "clean" })).toEqual({
active: false,
linked: false,
dirty: false,
})
expect(
inspectWorkspaceDeletion({
workspace: "/workspace",
sessions: [
{ location: { directory: "/workspace" }, time: { created: 1, updated: 1, archived: 2 } } as SessionInfo,
],
status: "clean",
}),
).toEqual({ active: false, linked: false, dirty: false })
})
test("groups nested non-archived workspace sessions by latest activity", () => {
const session = (id: string, directory: string, updated: number, archived?: number) =>
({ id, location: { directory }, time: { created: 1, updated, archived } }) as SessionInfo
const sessions = sessionsForWorkspace(
[
session("old", "/workspace", 2),
session("nested", "/workspace/packages/app", 3),
session("archived", "/workspace", 4, 5),
session("other", "/other", 6),
],
"/workspace",
)
expect(sessions.map((item) => item.id)).toEqual(["nested", "old"])
})
test("merges workspace placement by freshness with authoritative server ties", () => {
const session = (directory: string, updated: number) =>
({ id: "session", location: { directory }, time: { created: 1, updated } }) as SessionInfo
expect(
mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 2)])[0]?.location.directory,
).toBe("/destination")
expect(
mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 3)])[0]?.location.directory,
).toBe("/destination")
expect(
mergeWorkspaceSessionInventory([session("/destination", 2)], [session("/source", 3)])[0]?.location.directory,
).toBe("/source")
})
+116
View File
@@ -0,0 +1,116 @@
import { pathKey } from "@/utils/path-key"
import type { WorkspaceDefaultDestination, WorkspaceLastUsed } from "@/context/settings"
import type { SessionInfo, WorktreeDirectory } from "@opencode-ai/client/promise"
type WorkspaceProject = {
worktree: string
sandboxes?: readonly string[]
worktrees?: readonly WorktreeDirectory[]
}
export function workspaceDirectories(project: WorkspaceProject) {
return (project.sandboxes ?? []).filter((directory) => !sameDirectory(project.worktree, directory))
}
export function managedWorkspaceDirectories(project: WorkspaceProject) {
return (project.worktrees ?? [])
.filter((worktree) => worktree.strategy !== undefined)
.map((worktree) => worktree.directory)
.filter((directory) => !sameDirectory(project.worktree, directory))
}
export function workspaceInventory<T extends WorkspaceProject & { id: string }>(projects: readonly T[]) {
return projects.flatMap((project) =>
managedWorkspaceDirectories(project).map((directory) => ({ directory, project })),
)
}
export function filterWorkspaceInventory<T extends { project: { id: string } }>(
workspaces: readonly T[],
project: string,
) {
if (project === "all") return [...workspaces]
return workspaces.filter((workspace) => workspace.project.id === project)
}
export function sessionsForWorkspace(sessions: readonly SessionInfo[], workspace: string) {
return sessions
.filter((session) => session.time.archived === undefined)
.filter((session) => containsDirectory(workspace, session.location.directory))
.toSorted((a, b) => b.time.updated - a.time.updated)
}
export function mergeWorkspaceSessionInventory(server: readonly SessionInfo[], cached: readonly SessionInfo[]) {
const sessions = new Map(server.map((session) => [session.id, session]))
cached.forEach((session) => {
const current = sessions.get(session.id)
if (!current || session.time.updated > current.time.updated) sessions.set(session.id, session)
})
return [...sessions.values()]
}
export function removeWorkspacesSequentially<T>(workspaces: readonly T[], remove: (workspace: T) => Promise<void>) {
return workspaces.reduce((previous, workspace) => previous.then(() => remove(workspace)), Promise.resolve())
}
export type WorkspaceDeleteInspection = {
active: boolean
linked: boolean
dirty: boolean
}
export function inspectWorkspaceDeletion(input: {
workspace: string
activeDirectory?: string
sessions: readonly SessionInfo[]
status: "clean" | "dirty"
}): WorkspaceDeleteInspection {
return {
active: !!input.activeDirectory && containsDirectory(input.workspace, input.activeDirectory),
linked: input.sessions.some(
(session) =>
session.time.archived === undefined && containsDirectory(input.workspace, session.location.directory),
),
dirty: input.status === "dirty",
}
}
export function isWorkspaceDirectory(project: WorkspaceProject | undefined, directory: string) {
if (!project || sameDirectory(project.worktree, directory)) return false
return workspaceDirectories(project).some((workspace) => containsDirectory(workspace, directory))
}
export function isProjectDirectory(project: WorkspaceProject | undefined, directory: string) {
if (!project) return false
return [project.worktree, ...(project.sandboxes ?? [])].some((root) => containsDirectory(root, directory))
}
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 sameDirectory(a: string, b: string) {
return containsDirectory(a, b) && containsDirectory(b, a)
}
export function isWorkspaceSelection(project: WorkspaceProject | undefined, selection: string) {
if (selection === "main" || selection === "create") return true
if (!project) return false
if (sameDirectory(project.worktree, selection)) return true
return isWorkspaceDirectory(project, selection)
}
export function workspaceDefaultSelection(
setting: WorkspaceDefaultDestination,
lastUsed: WorkspaceLastUsed | undefined,
) {
if (setting === "local") return "main"
if (setting === "new") return "create"
return lastUsed === "workspace" ? "create" : "main"
}
@@ -15,8 +15,6 @@ export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
const requestedDirectory = Option.getOrUndefined(input.directory)
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
const preflight = UpdatePreflight.make()
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const server = yield* ServerConnection.resolve({
@@ -36,6 +34,8 @@ export default Runtime.handler(Commands, (input) =>
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
),
)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -239,58 +239,5 @@ export class SQLiteEffectDatabase<
) => Effect.Effect<A, E | SqlError, R> = (tx, config) => this.session.transaction(tx, config)
}
export type SQLiteEffectWithReplicas<Q> = Q & { $primary: Q; $replicas: Q[] }
export const withReplicas = <
TEffectHKT extends QueryEffectHKTBase,
TRunResult,
TRelations extends AnyRelations,
Q extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations>,
>(
primary: Q,
replicas: [Q, ...Q[]],
getReplica: (replicas: Q[]) => Q = () => replicas[Math.floor(Math.random() * replicas.length)]!,
): SQLiteEffectWithReplicas<Q> => {
const select: Q["select"] = (...args: []) => getReplica(replicas).select(...args)
const selectDistinct: Q["selectDistinct"] = (...args: []) => getReplica(replicas).selectDistinct(...args)
const $count: Q["$count"] = (...args: [any]) => getReplica(replicas).$count(...args)
const _with: Q["with"] = (...args: []) => getReplica(replicas).with(...args)
const $with = ((...args: [string] | [string, ColumnsSelection]) =>
args.length === 1
? getReplica(replicas).$with(args[0])
: getReplica(replicas).$with(args[0], args[1])) as Q["$with"]
const update: Q["update"] = (...args: [any]) => primary.update(...args)
const insert: Q["insert"] = (...args: [any]) => primary.insert(...args)
const $delete: Q["delete"] = (...args: [any]) => primary.delete(...args)
const run: Q["run"] = (...args: [any]) => primary.run(...args)
const all: Q["all"] = (...args: [any]) => primary.all(...args)
const get: Q["get"] = (...args: [any]) => primary.get(...args)
const values: Q["values"] = (...args: [any]) => primary.values(...args)
const transaction: Q["transaction"] = (...args: [any]) => primary.transaction(...args)
return {
...primary,
update,
insert,
delete: $delete,
run,
all,
get,
values,
transaction,
$primary: primary,
$replicas: replicas,
select,
selectDistinct,
$count,
$with,
with: _with,
get query() {
return getReplica(replicas).query
},
}
}
export type AnySQLiteEffectDatabase = SQLiteEffectDatabase<any, any, any>
export type AnySQLiteEffectSelectBase = SQLiteEffectSelectBase<any, any, any, any, any, any, any, any, any, any>
@@ -1,22 +1,10 @@
/* oxlint-disable */
import type { TablesRelationalConfig } from "drizzle-orm/_relations"
import type { MigrationMeta } from "drizzle-orm/migrator"
import type { AnyRelations } from "drizzle-orm/relations"
import { type SQL, sql } from "drizzle-orm/sql/sql"
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"
import type { SQLiteSession } from "drizzle-orm/sqlite-core/session"
import { GET_VERSION_FOR, MIGRATIONS_TABLE_VERSIONS, type UpgradeResult } from "./utils.js"
/** @internal */
export type SQLiteMigrationTableRow = { id: number | null; hash: string; created_at: number }
type AsyncSQLiteDatabaseWithSession = BaseSQLiteDatabase<"async", unknown, Record<string, unknown>> & {
session: {
all<T>(query: SQL): Promise<T[]>
}
transaction<T>(transaction: (tx: { run(query: SQL): Promise<unknown> }) => Promise<T>): Promise<T>
}
type SQLiteMigrationBackfillEntry = {
name: string
selector:
@@ -115,139 +103,3 @@ export function buildSQLiteMigrationBackfillStatements(
return statements
}
/**
* Detects the current version of the migrations table schema and upgrades it if needed.
*
* Version 0: Original schema (id, hash, created_at)
* Version 1: Extended schema (id, hash, created_at, name, applied_at)
*/
export function upgradeSyncIfNeeded(
migrationsTable: string,
session: SQLiteSession<"sync", unknown, Record<string, unknown>, AnyRelations, TablesRelationalConfig>,
localMigrations: MigrationMeta[],
): UpgradeResult {
const tableExists = session.all(sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`)
if (tableExists.length === 0) {
return { newDb: true }
}
// Table exists, check table shape
const rows = session.all<{ column_name: string }>(
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
)
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
const upgradeFn = upgradeSyncFunctions[v]
if (!upgradeFn) {
throw new Error(`No upgrade path from migration table version ${v} to ${v + 1}`)
}
upgradeFn(migrationsTable, session, localMigrations)
}
return { newDb: false }
}
const upgradeSyncFunctions: Record<
number,
(
migrationsTable: string,
session: SQLiteSession<"sync", unknown, Record<string, unknown>, AnyRelations, TablesRelationalConfig>,
localMigrations: MigrationMeta[],
) => void
> = {
/**
* Upgrade from version 0 to version 1:
* 1. Read all existing DB migrations
* 2. Sort localMigrations ASC by millis and if the same - sort by name
* 3. Match each DB row to a local migration
* If multiple migrations share the same second, use hash matching as a tiebreaker
* Not implemented for now -> If hash matching fails, fall back to serial id ordering
* 5. Create extra column and backfill names for matched migrations
*/
0: (migrationsTable, session, localMigrations) => {
const table = sql`${sql.identifier(migrationsTable)}`
const dbRows = session.all<SQLiteMigrationTableRow>(sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`)
const statements = buildSQLiteMigrationBackfillStatements(
migrationsTable,
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
)
session.transaction((tx) => {
for (const statement of statements) {
tx.run(statement)
}
})
},
}
/**
* Detects the current version of the migrations table schema and upgrades it if needed.
*
* Version 0: Original schema (id, hash, created_at)
* Version 1: Extended schema (id, hash, created_at, name, applied_at)
*/
export async function upgradeAsyncIfNeeded(
migrationsTable: string,
db: AsyncSQLiteDatabaseWithSession,
localMigrations: MigrationMeta[],
): Promise<UpgradeResult> {
// Check if the table exists at all
const tableExists = await db.session.all(
sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`,
)
if (tableExists.length === 0) {
return { newDb: true }
}
const rows = await db.session.all<{ column_name: string }>(
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
)
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
const upgradeFn = upgradeAsyncFunctions[v]
if (!upgradeFn) {
throw new Error(`No upgrade path from migration table version ${v} to ${v + 1}`)
}
await upgradeFn(migrationsTable, db, localMigrations)
}
return { newDb: false }
}
const upgradeAsyncFunctions: Record<
number,
(migrationsTable: string, db: AsyncSQLiteDatabaseWithSession, localMigrations: MigrationMeta[]) => Promise<void>
> = {
/**
* Upgrade from version 0 to version 1:
* 1. Read all existing DB migrations
* 2. Sort localMigrations ASC by millis and if the same - sort by name
* 3. Match each DB row to a local migration
* If multiple migrations share the same second, use hash matching as a tiebreaker
* Not implemented for now -> If hash matching fails, fall back to serial id ordering
* 5. Create extra column and backfill names for matched migrations
*/
0: async (migrationsTable, db, localMigrations) => {
const table = sql`${sql.identifier(migrationsTable)}`
const dbRows = await db.session.all<SQLiteMigrationTableRow>(
sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`,
)
const statements = buildSQLiteMigrationBackfillStatements(
migrationsTable,
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
)
await db.transaction(async (tx) => {
for (const statement of statements) {
await tx.run(statement)
}
})
},
}
-12
View File
@@ -195,18 +195,6 @@ export function hash(value: Schema.Json) {
return Hash.make(createHash("sha256").update(canonical(value)).digest("hex"))
}
export function applyDelta(
values: Readonly<Record<string, Schema.Json>>,
delta: Readonly<Record<string, Option.Option<Schema.Json>>>,
): Readonly<Record<string, Schema.Json>> {
const result: Record<string, Schema.Json> = { ...values }
for (const [key, value] of Object.entries(delta)) {
if (Option.isNone(value)) delete result[key]
else result[key] = value.value
}
return result
}
export function applyHashDelta(values: Values, delta: Delta): Values {
const result: Record<string, Hash> = { ...values }
for (const [key, value] of Object.entries(delta)) {
+33 -11
View File
@@ -741,21 +741,43 @@ const layer = Layer.effect(
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
const project = yield* projects.resolve(directory)
yield* persistProject(project)
const payload: SessionInbox.MovePayload = {
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
}
const item = SessionInbox.Item.make({
type: "move",
payload: {
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
},
payload,
delivery: input.delivery ?? "steer",
})
const inboxID = SessionMessage.ID.create()
yield* SessionInbox.admit(db, bus, {
id: inboxID,
sessionID: input.sessionID,
item,
})
const recovered = yield* SessionInbox.serialized(
input.sessionID,
Effect.gen(function* () {
const latest = yield* result.get(input.sessionID)
const source = yield* fs.stat(latest.location.directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!source || source.type !== "Directory") {
const cancellations = (yield* SessionInbox.moveIDs(db, input.sessionID)).map(
(item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const,
)
const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...payload }] as const
const first = cancellations[0]
if (!first) {
yield* bus.publish(...moved)
return true
}
yield* bus.publishAll([first, ...cancellations.slice(1), moved])
return true
}
yield* SessionInbox.admit(db, bus, {
id: SessionMessage.ID.create(),
sessionID: input.sessionID,
item,
})
return false
}),
)
if (recovered) return
yield* execution.wake(input.sessionID)
}),
compact: Effect.fn("Session.compact")(function* (input) {
+10
View File
@@ -309,6 +309,16 @@ export const list = Effect.fn("SessionInbox.list")(function* (db: DatabaseServic
return rows.map(fromRow)
})
export const moveIDs = Effect.fn("SessionInbox.moveIDs")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select({ id: SessionInboxTable.id })
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.type, "move")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.all()
.pipe(Effect.orDie)
})
export const nextQueued = Effect.fn("SessionInbox.nextQueued")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
@@ -0,0 +1 @@
export const MAX_MARKDOWN_BYTES = 5 * 1024 * 1024
+3 -1
View File
@@ -1,4 +1,7 @@
import { Parser } from "htmlparser2"
import { MAX_MARKDOWN_BYTES } from "./html-markdown-limit.js"
export { MAX_MARKDOWN_BYTES } from "./html-markdown-limit.js"
const omitted = new Set(["script", "style", "noscript", "iframe", "object", "embed", "meta", "link", "template"])
const blocks = new Set([
@@ -47,7 +50,6 @@ type Frame = {
type Chunk = string | { raw: string }
export const MAX_MARKDOWN_BYTES = 5 * 1024 * 1024
const CONTENT_BYTES = MAX_MARKDOWN_BYTES - 64 * 1024
export function convertHTMLToMarkdown(html: string) {
@@ -0,0 +1,23 @@
import { Parser } from "htmlparser2"
import { convertHTMLToMarkdown } from "../html-markdown.js"
export { convertHTMLToMarkdown }
export function extractTextFromHTML(html: string) {
let text = ""
let skipDepth = 0
const parser = new Parser({
onopentag(name) {
if (skipDepth > 0 || ["script", "style", "noscript", "iframe", "object", "embed"].includes(name)) skipDepth++
},
ontext(input) {
if (skipDepth === 0) text += input
},
onclosetag() {
if (skipDepth > 0) skipDepth--
},
})
parser.write(html)
parser.end()
return text.trim()
}
+6 -27
View File
@@ -4,9 +4,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2"
import { Permission } from "../../permission.js"
import { convertHTMLToMarkdown, MAX_MARKDOWN_BYTES } from "../html-markdown.js"
import { MAX_MARKDOWN_BYTES } from "../html-markdown-limit.js"
import { collectBoundedResponseBody } from "../http-body.js"
export const name = "webfetch"
@@ -103,11 +102,12 @@ const isTextualMime = (mime: string) =>
mime.endsWith("+xml") ||
mime === "application/javascript" ||
mime === "application/x-javascript"
const convert = (content: string, contentType: string, format: Format) => {
const convert = async (content: string, contentType: string, format: Format) => {
if (!contentType.includes("text/html")) return content
if (format === "html") return content
const { convertHTMLToMarkdown, extractTextFromHTML } = await import("./webfetch-convert.js")
if (format === "markdown") return convertHTMLToMarkdown(content)
if (format === "text") return extractTextFromHTML(content)
return content
return extractTextFromHTML(content)
}
export const Plugin = {
@@ -159,7 +159,7 @@ export const Plugin = {
}),
)
const content = new TextDecoder().decode(body)
const output = yield* Effect.try({
const output = yield* Effect.tryPromise({
try: () => convert(content, contentType, input.format),
catch: (error) => error,
})
@@ -176,24 +176,3 @@ export const Plugin = {
.pipe(Effect.orDie)
}),
}
export function extractTextFromHTML(html: string) {
let text = ""
let skipDepth = 0
const parser = new Parser({
onopentag(name) {
if (skipDepth > 0 || ["script", "style", "noscript", "iframe", "object", "embed"].includes(name)) skipDepth++
},
ontext(input) {
if (skipDepth === 0) text += input
},
onclosetag() {
if (skipDepth > 0) skipDepth--
},
})
parser.write(html)
parser.end()
return text.trim()
}
export { convertHTMLToMarkdown }
-1
View File
@@ -55,7 +55,6 @@ const integrations = Layer.mock(Integration.Service, {
})
const npm = Layer.mock(Npm.Service, {
add: () => Effect.die("unused"),
install: () => Effect.die("unused"),
which: () => Effect.die("unused"),
})
const aisdk = Layer.mock(AISDK.Service, {
@@ -125,9 +125,6 @@ describe("Instructions", () => {
expect(
Instructions.renderUpdate(instructions, { "api/value": "previous" }, { "api/value": Option.some(null) }),
).toBe("null")
expect(Instructions.applyDelta({ "api/value": "previous" }, { "api/value": Option.some(null) })).toEqual({
"api/value": null,
})
}),
)
+5 -1
View File
@@ -34,7 +34,11 @@ export const readUpdate = (instructions: Instructions.List, previous: State) =>
hash === "removed" ? Option.none() : Option.some(admission.blobs[hash]),
]),
) as Readonly<Record<string, Option.Option<Schema.Json>>>
const values = Instructions.applyDelta(previous.values, delta)
const values: Record<string, Schema.Json> = { ...previous.values }
for (const [key, value] of Object.entries(delta)) {
if (Option.isNone(value)) delete values[key]
else values[key] = value.value
}
return {
values,
text: Instructions.renderUpdate(instructions, previous.values, delta),
-1
View File
@@ -29,7 +29,6 @@ const npmLayer = Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
install: () => Effect.void,
which: () => Effect.succeed(undefined),
}),
)
@@ -23,7 +23,6 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
install: () => Effect.void,
which: () => Effect.succeed(undefined),
})
}
@@ -14,7 +14,6 @@ const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.ur
const it = testEffect(PluginTestLayer)
const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
install: () => Effect.void,
which: () => Effect.succeed(undefined),
})
+14 -16
View File
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import path from "path"
import { mkdir, rm } from "fs/promises"
import { Effect } from "effect"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Bus } from "@opencode-ai/core/bus"
@@ -29,7 +30,7 @@ const it = testEffect(
)
describe("Session.move", () => {
it.effect("enqueues one move when the source directory no longer exists", () =>
it.effect("applies a move immediately when the source directory no longer exists", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -38,29 +39,26 @@ describe("Session.move", () => {
Effect.gen(function* () {
const session = yield* Session.Service
const destination = AbsolutePath.make(tmp.path)
const source = path.join(tmp.path, "source")
yield* Effect.promise(() => mkdir(source))
const created = yield* session.create({
location: Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "deleted")) }),
location: Location.Ref.make({ directory: AbsolutePath.make(source) }),
})
yield* session.move({ sessionID: created.id, directory: destination })
expect((yield* session.get(created.id)).location.directory).toBe(AbsolutePath.make(source))
expect(yield* session.inbox(created.id)).toHaveLength(1)
expect((yield* session.get(created.id)).location.directory).toBe(
AbsolutePath.make(path.join(tmp.path, "deleted")),
)
expect(yield* session.inbox(created.id)).toMatchObject([
{
type: "move",
delivery: "steer",
payload: {
location: { directory: destination },
projectID: Project.ID.global,
},
},
])
yield* Effect.promise(() => rm(source, { recursive: true }))
yield* session.move({ sessionID: created.id, directory: destination })
expect((yield* session.get(created.id)).location.directory).toBe(destination)
expect(yield* session.inbox(created.id)).toEqual([])
yield* session.move({ sessionID: created.id, directory: destination })
expect(yield* session.inbox(created.id)).toHaveLength(2)
expect(yield* session.inbox(created.id)).toHaveLength(1)
yield* Effect.promise(() => mkdir(path.join(tmp.path, "other")))
const steered = yield* session.create({
location: Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "other")) }),
})
+69 -71
View File
@@ -9,6 +9,7 @@ import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool"
import { WebFetchTool } from "@opencode-ai/core/tool/plugin/webfetch"
import { convertHTMLToMarkdown, extractTextFromHTML } from "@opencode-ai/core/tool/plugin/webfetch-convert"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
@@ -70,52 +71,50 @@ describe("WebFetchTool helpers", () => {
test("ports HTML text and markdown conversions without active content", () => {
const html =
"<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong> <product-name>today</product-name></p><style>.bad {}</style>"
expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide today")
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide** today")
expect(extractTextFromHTML(html)).toBe("Helloworld wide today")
expect(convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide** today")
})
test("renders headings, inline semantics, links, images, breaks, and thematic breaks", () => {
const html = `<h2>Read <em>this</em></h2><p><a href="https://example.com/a (b)" title="Example">docs</a><br><img src="diagram.png" alt="a ] b"></p><hr><p><del>old</del></p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
expect(convertHTMLToMarkdown(html)).toBe(
`## Read *this*\n\n[docs](https://example.com/a%20\\(b\\) "Example") \n![a \\] b](diagram.png)\n\n---\n\n~~old~~`,
)
})
test("preserves inline and preformatted code verbatim with safe fences", () => {
const html = `<p>Use <code>say(\`hello\`)</code> now.</p><pre><code class="language-ts">const fence = \`\`\`\n&amp; stays decoded</code></pre>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
expect(convertHTMLToMarkdown(html)).toBe(
`Use \`\`say(\`hello\`)\`\` now.\n\n~~~ts\nconst fence = \`\`\`\n& stays decoded\n~~~`,
)
})
test("keeps nested ordered and unordered lists structurally readable", () => {
const html = `<ol start="3"><li>alpha<ul><li>nested <strong>item</strong></li></ul></li><li><p>beta first</p><p>beta second</p></li></ol>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`3. alpha\n\n - nested **item**\n\n4. beta first\n\n beta second`,
)
expect(convertHTMLToMarkdown(html)).toBe(`3. alpha\n\n - nested **item**\n\n4. beta first\n\n beta second`)
})
test("renders blockquotes and tables as readable Markdown", () => {
const html = `<blockquote><p>quoted <em>text</em></p><ul><li>point</li></ul></blockquote><table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>one</td><td><code>1</code></td></tr></tbody></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
expect(convertHTMLToMarkdown(html)).toBe(
`> quoted *text*\n\n> - point\n\n| Name | Value |\n| --- | --- |\n| one | \`1\` |`,
)
})
test("decodes entities and normalizes prose whitespace without joining words", () => {
const html = `<p>alpha\n <span>&amp; beta</span> <unknown>caf&eacute;</unknown>&nbsp;gamma 😀</p><p>delta</p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`alpha & beta café gamma 😀\n\ndelta`)
expect(convertHTMLToMarkdown(html)).toBe(`alpha & beta café gamma 😀\n\ndelta`)
})
test("omits active and fallback content while retaining surrounding prose", () => {
const html = `<p>before <script><b>bad</b></script><style>bad</style><noscript>bad</noscript><iframe>bad</iframe><object>bad</object><embed src="bad"><meta content="bad"><link href="bad"><template>bad</template> after</p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("before after")
expect(convertHTMLToMarkdown(html)).toBe("before after")
})
test("is deterministic and bounded for malformed maximum-size input", () => {
const html = `<main><p>${"visible &amp; text ".repeat(250_000)}</main></p></unknown>`
const first = WebFetchTool.convertHTMLToMarkdown(html)
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(first)
const first = convertHTMLToMarkdown(html)
expect(convertHTMLToMarkdown(html)).toBe(first)
expect(first.startsWith("visible & text visible & text")).toBe(true)
expect(first.length).toBeLessThanOrEqual(html.length)
})
@@ -124,64 +123,62 @@ describe("WebFetchTool helpers", () => {
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
const code = `<pre>${"` x ".repeat(250_000)}</pre>`
expect(WebFetchTool.convertHTMLToMarkdown(lists).length).toBeLessThan(lists.length * 4)
expect(WebFetchTool.convertHTMLToMarkdown(quotes).length).toBeLessThan(quotes.length * 4)
expect(() => WebFetchTool.convertHTMLToMarkdown(code)).not.toThrow()
expect(convertHTMLToMarkdown(lists).length).toBeLessThan(lists.length * 4)
expect(convertHTMLToMarkdown(quotes).length).toBeLessThan(quotes.length * 4)
expect(() => convertHTMLToMarkdown(code)).not.toThrow()
expect(
WebFetchTool.convertHTMLToMarkdown(
"<div>".repeat(20_000) + "safe<script><b>bad</b>&amp;</script><p>tail &amp;</p>",
),
convertHTMLToMarkdown("<div>".repeat(20_000) + "safe<script><b>bad</b>&amp;</script><p>tail &amp;</p>"),
).toBe("safe tail &")
})
test("escapes prose that would otherwise become Markdown structure", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p># heading</p><p>1. item</p><p>---</p><p>a | b</p>`)).toBe(
expect(convertHTMLToMarkdown(`<p># heading</p><p>1. item</p><p>---</p><p>a | b</p>`)).toBe(
`\\# heading\n\n1\\. item\n\n\\---\n\na \\| b`,
)
})
test("preserves code whitespace and quotes every line of multiline blocks", () => {
const html = `<blockquote><pre>line \n\n\nnext</pre><table><tr><td>a|b</td><td>c</td></tr></table></blockquote>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
expect(convertHTMLToMarkdown(html)).toBe(
`> \`\`\`\n> line \n> \n> \n> next\n> \`\`\`\n\n> | a\\|b | c |\n> | --- | --- |`,
)
})
test("keeps nested blockquotes inside their outer quote", () => {
const html = `<blockquote><p>outer</p><blockquote><p>inner</p></blockquote><p>end</p></blockquote>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`> outer\n>\n> > inner\n>\n> end`)
expect(convertHTMLToMarkdown(html)).toBe(`> outer\n>\n> > inner\n>\n> end`)
})
test("keeps visible whitespace around inline emphasis", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p>a<strong> b</strong> c a <em>b </em>c</p>`)).toBe(`a **b** c a *b* c`)
expect(WebFetchTool.convertHTMLToMarkdown(`a<strong> </strong>b a<em> </em>b`)).toBe(`a b a b`)
expect(convertHTMLToMarkdown(`<p>a<strong> b</strong> c a <em>b </em>c</p>`)).toBe(`a **b** c a *b* c`)
expect(convertHTMLToMarkdown(`a<strong> </strong>b a<em> </em>b`)).toBe(`a b a b`)
})
test("captures formatting elements inside preformatted content as code only", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<pre><b>x</b><i>y</i><del>z</del></pre>`)).toBe(`\`\`\`\nxyz\n\`\`\``)
expect(convertHTMLToMarkdown(`<pre><b>x</b><i>y</i><del>z</del></pre>`)).toBe(`\`\`\`\nxyz\n\`\`\``)
})
test("normalizes multiline table cells without changing their columns", () => {
const html = `<table><tr><td>x<br>y</td><td><code>a|b</code></td><td><p>first</p><p>second</p></td></tr></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`| x y | \`a\\|b\` | first second |\n| --- | --- | --- |`)
expect(convertHTMLToMarkdown(html)).toBe(`| x y | \`a\\|b\` | first second |\n| --- | --- | --- |`)
})
test("flattens nested tables without corrupting the outer table", () => {
const html = `<table><tr><th>Parent</th><th>Sibling</th></tr><tr><td>Before<table><tr><th>Key</th><th>Value</th></tr><tr><td>A</td><td>1</td></tr></table>After</td><td>Tail</td></tr></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
expect(convertHTMLToMarkdown(html)).toBe(
`| Parent | Sibling |\n| --- | --- |\n| Before Key Value A 1 After | Tail |`,
)
})
test("preserves loose text around malformed table rows", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<table>before<tr><td>cell</td></tr>after</table>`)).toBe(
expect(convertHTMLToMarkdown(`<table>before<tr><td>cell</td></tr>after</table>`)).toBe(
`before after\n\n| cell |\n| --- |`,
)
expect(WebFetchTool.convertHTMLToMarkdown(`<table>alpha</table>`)).toBe(`alpha`)
expect(convertHTMLToMarkdown(`<table>alpha</table>`)).toBe(`alpha`)
})
test("escapes tilde fences and removes empty emphasis markers", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p>~~~</p><p><strong></strong>content</p><p>~~~</p>`)).toBe(
expect(convertHTMLToMarkdown(`<p>~~~</p><p><strong></strong>content</p><p>~~~</p>`)).toBe(
`\\~\\~\\~\n\ncontent\n\n\\~\\~\\~`,
)
})
@@ -190,10 +187,10 @@ describe("WebFetchTool helpers", () => {
const small = "<a".repeat(250_000)
const large = "<a".repeat(1_000_000)
const start = Bun.nanoseconds()
WebFetchTool.convertHTMLToMarkdown(small)
convertHTMLToMarkdown(small)
const smallDuration = Bun.nanoseconds() - start
const next = Bun.nanoseconds()
WebFetchTool.convertHTMLToMarkdown(large)
convertHTMLToMarkdown(large)
const largeDuration = Bun.nanoseconds() - next
expect(largeDuration).toBeLessThan(smallDuration * 10)
})
@@ -201,73 +198,69 @@ describe("WebFetchTool helpers", () => {
test("caps escaped prose and backtick-heavy pre output at the webfetch response ceiling", () => {
const prose = `<p>${"*".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</p>`
const code = `<pre>${"`".repeat(WebFetchTool.MAX_RESPONSE_BYTES - 11)}</pre>`
const proseOutput = WebFetchTool.convertHTMLToMarkdown(prose)
const codeOutput = WebFetchTool.convertHTMLToMarkdown(code)
const proseOutput = convertHTMLToMarkdown(prose)
const codeOutput = convertHTMLToMarkdown(code)
expect(Buffer.byteLength(proseOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
expect(Buffer.byteLength(codeOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
expect(codeOutput.startsWith("~~~\n")).toBe(true)
})
test("does not confuse source NUL text with buffered code", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p>before \u00000\u0000 after</p><pre>code</pre>`)).toBe(
expect(convertHTMLToMarkdown(`<p>before \u00000\u0000 after</p><pre>code</pre>`)).toBe(
`before \u00000\u0000 after\n\n\`\`\`\ncode\n\`\`\``,
)
})
test("preserves multiline inline code verbatim", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p><code>first\n\n\nsecond </code></p>`)).toBe(
"` first\n\n\nsecond `",
)
expect(convertHTMLToMarkdown(`<p><code>first\n\n\nsecond </code></p>`)).toBe("` first\n\n\nsecond `")
})
test("prefixes inline code at the start of a blockquote line", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<blockquote><code>x</code> y</blockquote>`)).toBe(`> \`x\` y`)
expect(convertHTMLToMarkdown(`<blockquote><code>x</code> y</blockquote>`)).toBe(`> \`x\` y`)
})
test("keeps links nested in inline code associated with their text", () => {
const html = `<dl><dt><code>socket = new <a href="#constructor">WebSocket</a>(url)</code><dd>Creates one.</dl>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
expect(convertHTMLToMarkdown(html)).toBe(
`**\` socket = new \`[\`WebSocket\`](#constructor)\`(url)\`**\n: Creates one.`,
)
expect(WebFetchTool.convertHTMLToMarkdown(`<code><a href="#x">x</a></code> after`)).toBe(`[\`x\`](#x) after`)
expect(convertHTMLToMarkdown(`<code><a href="#x">x</a></code> after`)).toBe(`[\`x\`](#x) after`)
expect(
WebFetchTool.convertHTMLToMarkdown(
convertHTMLToMarkdown(
`<dl><dt><code><var>socket</var> = new <code><a href="#constructor">WebSocket</a></code>(<var>url</var>)</code><dd>Creates one.</dl>`,
),
).toBe(`**\` socket = new \`[\`WebSocket\`](#constructor)\`(url)\`**\n: Creates one.`)
expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x">b<a href="/y">c</a>d</a>e</code>`)).toBe(
expect(convertHTMLToMarkdown(`<code>a<a href="/x">b<a href="/y">c</a>d</a>e</code>`)).toBe(
`\`a\`[\`b\`](\/x)[\`c\`](\/y)\`de\``,
)
expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x">b</code>c`)).toBe(`\`a\`[\`b\`](\/x)c`)
expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x"><div>b</div>c</a>d</code>`)).toBe(
`\`a\`[](\/x)\n\n\`bcd\``,
)
expect(convertHTMLToMarkdown(`<code>a<a href="/x">b</code>c`)).toBe(`\`a\`[\`b\`](\/x)c`)
expect(convertHTMLToMarkdown(`<code>a<a href="/x"><div>b</div>c</a>d</code>`)).toBe(`\`a\`[](\/x)\n\n\`bcd\``)
})
test("indents nested list continuations and preserves ordered numbering", () => {
const html = `<ol start="0"><li value="4"><p>first</p><p>continued</p><ul><li><p>nested</p><p>continued nested</p></li></ul></li><li>next</li></ol>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
expect(convertHTMLToMarkdown(html)).toBe(
`4. first\n\n continued\n\n - nested\n\n continued nested\n\n5. next`,
)
})
test("renders block content outside link syntax", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<a href="/docs">before<div>block</div>after</a>`)).toBe(
expect(convertHTMLToMarkdown(`<a href="/docs">before<div>block</div>after</a>`)).toBe(
`[before](/docs)\n\nblock\n\n[after](/docs)`,
)
})
test("recovers nested anchors without unmatched Markdown syntax", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<a href="/a">x<a href="/b">y</a>z</a>`)).toBe(`[x](/a)[y](/b)z`)
expect(convertHTMLToMarkdown(`<a href="/a">x<a href="/b">y</a>z</a>`)).toBe(`[x](/a)[y](/b)z`)
})
test("keeps emphasis whitespace through neutral wrappers", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p>a<strong><span> bold</span></strong>c</p>`)).toBe(`a **bold** c`)
expect(convertHTMLToMarkdown(`<p>a<strong><span> bold</span></strong>c</p>`)).toBe(`a **bold** c`)
})
test("flattens preformatted content inside table cells", () => {
const html = `<table><tr><td><pre>a|b\nnext</pre></td><td><code>x|y</code></td></tr></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`| a\\|b next | \`x\\|y\` |\n| --- | --- |`)
expect(convertHTMLToMarkdown(html)).toBe(`| a\\|b next | \`x\\|y\` |\n| --- | --- |`)
})
test("keeps each near-boundary inline construct closed and UTF-8-safe", () => {
@@ -279,7 +272,7 @@ describe("WebFetchTool helpers", () => {
[`<code>${payload}</code>`, /^`[\s\S]*`$/],
] as const
for (const [html, pattern] of cases) {
const output = WebFetchTool.convertHTMLToMarkdown(html)
const output = convertHTMLToMarkdown(html)
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
expect(output).not.toContain("")
expect(output).toMatch(pattern)
@@ -288,11 +281,9 @@ describe("WebFetchTool helpers", () => {
test("keeps near-boundary block constructs syntactically complete", () => {
const payload = "x".repeat(WebFetchTool.MAX_RESPONSE_BYTES)
const table = WebFetchTool.convertHTMLToMarkdown(
`<table><tr><th>Name</th></tr><tr><td>${payload}</td></tr></table>`,
)
const list = WebFetchTool.convertHTMLToMarkdown(`<ul><li>${payload}</li></ul><ul><li>nested</li></ul>`)
const code = WebFetchTool.convertHTMLToMarkdown(`<pre>${payload}</pre>`)
const table = convertHTMLToMarkdown(`<table><tr><th>Name</th></tr><tr><td>${payload}</td></tr></table>`)
const list = convertHTMLToMarkdown(`<ul><li>${payload}</li></ul><ul><li>nested</li></ul>`)
const code = convertHTMLToMarkdown(`<pre>${payload}</pre>`)
for (const output of [table, list, code]) {
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
expect(output).not.toContain("")
@@ -305,7 +296,7 @@ describe("WebFetchTool helpers", () => {
test("keeps quoted code within budget with a safe closed fence", () => {
const html = `<blockquote><pre>${"`".repeat(32)}${"~".repeat(32)}${"x".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</pre></blockquote>`
const output = WebFetchTool.convertHTMLToMarkdown(html)
const output = convertHTMLToMarkdown(html)
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
const lines = output.split("\n")
expect(lines[0]).toMatch(/^> (`{33}|~{33})$/)
@@ -314,14 +305,14 @@ describe("WebFetchTool helpers", () => {
test("separates reconstructed tables from adjacent inline and quoted content", () => {
const html = `intro<table><tr><td>x</td></tr></table>outro<blockquote>quote<table><tr><td>cell</td></tr></table></blockquote><ul><li>item<table><tr><td>cell</td></tr></table></li></ul>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
expect(convertHTMLToMarkdown(html)).toBe(
`intro\n\n| x |\n| --- |\n\noutro\n\n> quote\n\n> | cell |\n> | --- |\n\n- item\n\n| cell |\n| --- |`,
)
})
test("keeps multiline quoted code closed at the content budget", () => {
const html = `<blockquote><pre>${"x\n".repeat(WebFetchTool.MAX_RESPONSE_BYTES / 2)}</pre></blockquote><p>tail</p>`
const output = WebFetchTool.convertHTMLToMarkdown(html)
const output = convertHTMLToMarkdown(html)
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
expect((output.match(/(`{3}|~{3})/g) ?? []).length).toBe(2)
expect(output.includes("\uFFFD")).toBe(false)
@@ -330,54 +321,52 @@ describe("WebFetchTool helpers", () => {
test("keeps active content suppressed when depth fallback begins", () => {
const html = `<object>${"<div>".repeat(10_001)}LEAK${"</div>".repeat(10_001)}</object><p>visible</p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("visible")
expect(convertHTMLToMarkdown(html)).toBe("visible")
})
test("keeps visible text after depth fallback begins inside preformatted content", () => {
const html = `<pre>${"<i>".repeat(10_001)}visible${"</i>".repeat(10_001)}</pre><p>after</p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("visible after")
expect(convertHTMLToMarkdown(html)).toBe("visible after")
})
test("resumes links around every block structure", () => {
const html = `<a href="/x">before<blockquote><p>quote</p></blockquote><ul><li>item</li></ul><pre>code</pre><table><tr><td>cell</td></tr></table>after</a>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
expect(convertHTMLToMarkdown(html)).toBe(
`[before](/x)\n\n> quote\n\n- item\n\n\`\`\`\ncode\n\`\`\`\n\n| cell |\n| --- |\n\n[after](/x)`,
)
})
test("indents child lists from the actual parent marker width", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<ol start="100"><li>outer<ul><li>inner</li></ul></li></ol>`)).toBe(
expect(convertHTMLToMarkdown(`<ol start="100"><li>outer<ul><li>inner</li></ul></li></ol>`)).toBe(
`100. outer\n\n - inner`,
)
})
test("renders captions and definition lists with readable boundaries", () => {
const html = `<table><caption>Cache modes</caption><tr><th>Name</th><th>Meaning</th></tr><tr><td>A</td><td>Local</td></tr></table><dl><dt>Cache</dt><dd>A local store</dd><dt>Origin</dt><dd>The remote source</dd></dl>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
expect(convertHTMLToMarkdown(html)).toBe(
`Cache modes\n\n| Name | Meaning |\n| --- | --- |\n| A | Local |\n\n**Cache**\n: A local store\n\n**Origin**\n: The remote source`,
)
})
test("falls back to row-oriented text for table spans", () => {
const html = `<table><tr><th colspan="2">Group</th></tr><tr><td>A</td><td rowspan="2">Shared</td></tr><tr><td>B</td></tr></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`Group\n\nA | Shared\n\nB`)
expect(convertHTMLToMarkdown(html)).toBe(`Group\n\nA | Shared\n\nB`)
})
test("suppresses head and hidden subtrees while retaining visible body content", () => {
const html = `<head><title>noise</title></head><body><p>visible</p><div hidden>hidden</div><div aria-hidden="true">aria</div><div aria-hidden="false">shown</div></body>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`visible\n\nshown`)
expect(convertHTMLToMarkdown(html)).toBe(`visible\n\nshown`)
})
test("preserves pre breaks and normalizes multiline link titles", () => {
const html = `<pre>first<br>second</pre><p><a href="/x" title="line one\n line two">link</a></p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`\`\`\`\nfirst\nsecond\n\`\`\`\n\n[link](/x "line one line two")`,
)
expect(convertHTMLToMarkdown(html)).toBe(`\`\`\`\nfirst\nsecond\n\`\`\`\n\n[link](/x "line one line two")`)
})
test("renders closed and open details according to visibility", () => {
const html = `<details><summary>Closed</summary><p>secret</p></details><details open><summary>Open</summary><p>visible</p></details>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`Closed\n\nOpen\n\nvisible`)
expect(convertHTMLToMarkdown(html)).toBe(`Closed\n\nOpen\n\nvisible`)
})
})
@@ -399,6 +388,11 @@ describe("WebFetchTool registration", () => {
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
])
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "hello" }],
})
}),
)
@@ -482,6 +476,10 @@ describe("WebFetchTool registration", () => {
status: "completed",
content: [{ type: "text", text: "Helloworld" }],
})
expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "html" }))).toMatchObject({
status: "completed",
content: [{ type: "text", text: "<h1>Hello</h1><p>world</p><script>bad()</script>" }],
})
}),
)
+21 -4
View File
@@ -5,7 +5,14 @@ import { MetaProvider } from "@solidjs/meta"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { I18nProvider } from "@opencode-ai/ui/context"
import { pluralCategory, pluralKey, type UiI18nParams, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
import {
pluralCategory,
pluralKey,
type UiI18nParams,
type UiI18nPluralKey,
type UiPluralCategory,
type UiTranslate,
} from "@opencode-ai/ui/context/i18n"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
import { createEffect, createMemo, Suspense, type ParentProps } from "solid-js"
@@ -58,20 +65,30 @@ function detectLocale() {
function UiI18nBridge(props: ParentProps) {
const locale = createMemo(() => detectLocale())
const zh = uiZh as Partial<Record<string, string>>
const t = (key: keyof typeof uiEn, params?: UiI18nParams) => {
const translate = (key: keyof typeof uiEn, params?: UiI18nParams) => {
const value = locale() === "zh" ? (zh[key] ?? uiEn[key]) : uiEn[key]
const text = value ?? String(key)
return resolveTemplate(text, params)
}
const t = translate as UiTranslate
const pluralForm = (key: UiI18nPluralKey, category: UiPluralCategory, params?: UiI18nParams) => {
const candidate = pluralKey(key, category)
const fallback = pluralKey(key, "other")
const value =
locale() === "zh"
? (zh[candidate] ?? zh[fallback] ?? uiEn[candidate] ?? uiEn[fallback])
: (uiEn[candidate] ?? uiEn[fallback])
return resolveTemplate(value ?? fallback, params)
}
const plural = (key: UiI18nPluralKey, count: number, params?: UiI18nParams) =>
t(pluralKey(key, pluralCategory(locale(), count)), { ...params, count })
pluralForm(key, pluralCategory(locale(), count), { ...params, count })
createEffect(() => {
if (typeof document !== "object") return
document.documentElement.lang = locale()
})
return <I18nProvider value={{ locale, t, plural }}>{props.children}</I18nProvider>
return <I18nProvider value={{ locale, t, plural, pluralForm }}>{props.children}</I18nProvider>
}
export default function App() {
@@ -4,9 +4,4 @@ import { Event } from "./event.js"
import { Worktree } from "./worktree.js"
import { SessionEvent } from "./session-event.js"
export const SessionDurable = {
definitions: Event.durableMap(SessionEvent.DurableDefinitions),
schema: SessionEvent.Durable,
} as const
export const Durable = Event.durableMap([...SessionEvent.DurableDefinitions, Worktree.Event.Resolved])
-8
View File
@@ -17,14 +17,6 @@ export const ResourcesChanged = Event.ephemeral({
},
})
export const BrowserOpenFailed = Event.ephemeral({
type: "mcp.browser.open.failed",
schema: {
mcpName: Schema.String,
url: Schema.String,
},
})
// Emitted whenever a server's connection status settles (connected, failed, needs_auth, closed) so
// observers can refresh status without polling.
export const StatusChanged = Event.ephemeral({
+2
View File
@@ -1,6 +1,8 @@
## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, empty states, and displayed errors.
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
- Render count-sensitive copy through `i18n.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `i18n.t(...)`; `pluralForm(...)` is reserved for components that animate individual grammatical forms.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
@@ -1382,6 +1382,11 @@ body[data-new-layout] [data-component="user-message"] {
background: var(--v2-background-bg-layer-02);
}
body[data-new-layout] [data-workspace-session] [data-component="user-message"] [data-slot="user-message-text"] {
background: var(--v2-background-bg-accent);
color: var(--v2-text-text-contrast);
}
body:not([data-new-layout]) {
[data-component="user-message"] {
color: var(--text-strong);
@@ -550,7 +550,7 @@ export function getToolInfo(
icon: "code-lines",
title: i18n.t("ui.tool.patch"),
subtitle: input.files?.length
? `${input.files.length} ${i18n.t(input.files.length > 1 ? "ui.common.file.other" : "ui.common.file.one")}`
? `${input.files.length} ${i18n.plural("ui.common.file", input.files.length)}`
: undefined,
}
case "todowrite":
@@ -2344,7 +2344,7 @@ ToolRegistry.register({
const subtitle = createMemo(() => {
const count = files().length
if (count === 0) return ""
return `${count} ${i18n.t(count > 1 ? "ui.common.file.other" : "ui.common.file.one")}`
return `${count} ${i18n.plural("ui.common.file", count)}`
})
return (
@@ -2585,7 +2585,7 @@ ToolRegistry.register({
const count = questions().length
if (count === 0) return ""
if (completed()) return i18n.t("ui.question.subtitle.answered", { count })
return `${count} ${i18n.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
return `${count} ${i18n.plural("ui.common.question", count)}`
})
return (
@@ -27,9 +27,11 @@ function common(one: string, other: string) {
export function AnimatedCountLabel(props: { count: number; plural: UiI18nPluralKey; class?: string }) {
const i18n = useI18n()
const category = createMemo(() => pluralCategory(i18n.locale(), Math.round(props.count)))
const one = createMemo(() => split(i18n.t(pluralKey(props.plural, "one"))))
const other = createMemo(() => split(i18n.t(pluralKey(props.plural, "other"))))
const active = createMemo(() => split(i18n.t(pluralKey(props.plural, category()))))
const form = (category: ReturnType<typeof pluralCategory>) =>
i18n.pluralForm?.(props.plural, category) ?? (i18n.t as (key: string) => string)(pluralKey(props.plural, category))
const one = createMemo(() => split(form("one")))
const other = createMemo(() => split(form("other")))
const active = createMemo(() => split(form(category())))
const suffix = createMemo(() => common(one().after, other().after))
const splitSuffix = createMemo(
() =>
@@ -36,6 +36,7 @@ export type PromptInputV2Mode = "normal" | "shell"
export type PromptInputV2Props = {
controller: PromptInputV2Interaction
accentSubmit?: boolean
disabled?: boolean
readOnly?: boolean
borderUnderlay?: boolean
@@ -263,6 +264,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
mode={state.mode}
stopping={view.submit.stopping()}
disabled={!props.controller.canSubmit()}
accent={props.accentSubmit}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={props.controller.submit}
@@ -678,6 +680,7 @@ export function PromptInputV2SubmitButton(props: {
mode: PromptInputV2Mode
stopping: boolean
disabled: boolean
accent?: boolean
sendLabel: string
stopLabel: string
onSubmit: () => void
@@ -696,10 +699,16 @@ export function PromptInputV2SubmitButton(props: {
tabIndex={props.mode === "normal" ? undefined : -1}
icon={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
class="size-7 rounded-md p-[6px] shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
classList={{
"text-v2-text-text-contrast": !!props.accent && !props.stopping && !props.disabled,
"text-v2-icon-icon-muted": !props.accent || props.stopping || props.disabled,
}}
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
props.accent && !props.stopping && !props.disabled
? "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-accent) 0%,var(--v2-background-bg-accent) 100%)"
: "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
onClick={(event) => {
+6 -3
View File
@@ -665,14 +665,17 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
slash: { name: "new", aliases: ["clear"] },
run: () => {
const current =
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
: undefined
route.navigate({
type: "home",
location: newSessionLocation(
config.data.session.new_location,
paths.cwd,
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
: undefined,
current,
location.error?.location,
),
})
dialog.clear()
+63 -70
View File
@@ -11,7 +11,7 @@ import {
onCleanup,
untrack,
} from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { Portal, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
import { useData } from "../context/data"
@@ -37,8 +37,6 @@ import { projectName } from "../util/project"
import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../util/marquee"
import { useDialog } from "../ui/dialog"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keymap } from "../context/keymap"
import { moveSelection } from "../ui/select-controller"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
@@ -189,7 +187,6 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const dialog = useDialog()
const keymap = Keymap.use()
const actions = createMemo(() => {
const sessionID = props.state.sessionID
return [
@@ -205,73 +202,69 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
: []),
]
})
const [selected, setSelected] = createSignal(0)
const [selected, setSelected] = createSignal<number>()
const top = () => Math.max(0, Math.min(props.state.y + 1, dimensions().height - actions().length))
const left = () => Math.max(0, Math.min(props.state.x, dimensions().width - CONTEXT_MENU_WIDTH))
const run = (index: number) => {
const action = actions()[index]
props.onClose()
actions()[index]?.run()
action?.run()
}
createEffect(() => {
const popMode = keymap.mode.push("modal")
onCleanup(popMode)
})
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "escape", title: "Close tab menu", group: "Tabs", run: props.onClose },
{
bind: "up",
title: "Previous tab menu item",
group: "Tabs",
run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: -1, policy: "wrap" })),
},
{
bind: "down",
title: "Next tab menu item",
group: "Tabs",
run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: 1, policy: "wrap" })),
},
{ bind: "return", title: "Select tab menu item", group: "Tabs", run: () => run(selected()) },
],
}))
return (
<box
position="absolute"
left={left()}
top={top()}
height={actions().length}
width={CONTEXT_MENU_WIDTH}
zIndex={2500}
flexDirection="column"
backgroundColor={theme.background.default}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
>
<For each={actions()}>
{(action, index) => (
<box
width="100%"
paddingLeft={1}
paddingRight={1}
backgroundColor={selected() === index() ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setSelected(index())}
onMouseUp={(event) => {
event.preventDefault()
event.stopPropagation()
run(index())
}}
>
<text fg={theme.text.default} selectable={false}>
{action.title}
</text>
</box>
)}
</For>
</box>
<Portal>
<box
position="absolute"
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
zIndex={2500}
onMouseDown={(event) => {
props.onClose()
event.preventDefault()
event.stopPropagation()
}}
>
<box
position="absolute"
left={left()}
top={top()}
height={actions().length}
width={CONTEXT_MENU_WIDTH}
flexDirection="column"
backgroundColor={theme.background.default}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) props.onClose()
event.preventDefault()
event.stopPropagation()
}}
>
<For each={actions()}>
{(action, index) => (
<box
width="100%"
paddingLeft={1}
paddingRight={1}
backgroundColor={selected() === index() ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setSelected(index())}
onMouseOut={() => setSelected(undefined)}
onMouseUp={(event) => {
event.preventDefault()
event.stopPropagation()
if (event.button === RIGHT_MOUSE_BUTTON) return
run(index())
}}
>
<text fg={theme.text.default} selectable={false}>
{action.title}
</text>
</box>
)}
</For>
</box>
</box>
</Portal>
)
}
@@ -520,8 +513,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
setDragging(undefined)
if (!rail) return
setContextMenu({
x: event.x - rail.screenX,
y: event.y - rail.screenY,
x: event.x,
y: event.y,
sessionID: tab.sessionID,
title: tab.title,
})
@@ -712,7 +705,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onMouseDown={(event: MouseEvent) => {
if (event.button !== RIGHT_MOUSE_BUTTON) return
if (!rail) return
setContextMenu({ x: event.x - rail.screenX, y: event.y - rail.screenY })
setContextMenu({ x: event.x, y: event.y })
event.preventDefault()
event.stopPropagation()
}}
@@ -1037,8 +1030,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
if (event.button === RIGHT_MOUSE_BUTTON) {
setDragging(undefined)
setContextMenu({
x: event.x - (strip?.screenX ?? 0),
y: event.y - (strip?.screenY ?? 0),
x: event.x,
y: event.y,
sessionID: tab === NEW_SESSION_TAB ? undefined : tab.sessionID,
title: tab === NEW_SESSION_TAB ? undefined : tab.title,
})
@@ -1134,7 +1127,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onMouseOut={() => setAddHovered(false)}
onMouseDown={(event) => {
if (event.button !== RIGHT_MOUSE_BUTTON) return
setContextMenu({ x: event.x - (strip?.screenX ?? 0), y: event.y - (strip?.screenY ?? 0) })
setContextMenu({ x: event.x, y: event.y })
event.preventDefault()
event.stopPropagation()
}}
@@ -4,7 +4,13 @@ export function newSessionLocation(
mode: "launch" | "inherit",
launchDirectory: string,
current?: LocationRef,
unavailable?: LocationRef,
): LocationRef {
if (mode === "inherit" && current) return current
if (
mode === "inherit" &&
current &&
(current.directory !== unavailable?.directory || current.workspaceID !== unavailable.workspaceID)
)
return current
return { directory: launchDirectory }
}
+8 -1
View File
@@ -10,6 +10,7 @@ import { useConfig } from "../config"
import { useLocation } from "./location"
import { useStorage } from "./storage"
import { useTuiPaths } from "./runtime"
import { newSessionLocation } from "../config/new-session-location"
import {
closeSessionTab,
cycleSessionTab,
@@ -289,9 +290,15 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
add() {
if (!enabled()) return
const sessionID = current()
const currentLocation = (sessionID ? data.session.get(sessionID)?.location : undefined) ?? location.ref
route.navigate({
type: "home",
location: (sessionID ? data.session.get(sessionID)?.location : undefined) ?? location.ref,
location: newSessionLocation(
config.session.new_location,
paths.cwd,
currentLocation,
location.error?.location,
),
})
},
close(sessionID?: string) {
+26 -32
View File
@@ -1,10 +1,9 @@
import { createContext, createSignal, onCleanup, useContext, type ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../context/theme"
import { useTerminalDimensions } from "@opentui/solid"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "./border"
import { TextAttributes } from "@opentui/core"
import { tint } from "../theme/color"
export type ToastOptions = {
title?: string
message: string
@@ -25,11 +24,23 @@ function ToastSurface(props: {
}) {
const theme = useTheme("overlay")
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const [hovered, setHovered] = createSignal(false)
const hover = (value: boolean) => {
setHovered(value)
props.onHover?.(value)
}
const affordance = () => (
<text
flexShrink={0}
marginLeft={2}
wrapMode="none"
attributes={hovered() && props.toast.action ? TextAttributes.BOLD : undefined}
fg={hovered() ? theme.text.action.primary.default : theme.text.subdued}
>
{props.toast.action ? ` ${props.toast.action.label}` : "x"}
</text>
)
return (
<box
@@ -44,7 +55,10 @@ function ToastSurface(props: {
customBorderChars={SplitBorder.customBorderChars}
onMouseOver={() => hover(true)}
onMouseOut={() => hover(false)}
onMouseUp={props.onActivate}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
props.onActivate()
}}
>
<box
width="100%"
@@ -52,9 +66,7 @@ function ToastSurface(props: {
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={
hovered() ? tint(theme.background.default, theme.text.default, 0.04) : theme.background.default
}
backgroundColor={theme.background.default}
>
<Show
when={props.toast.title}
@@ -63,18 +75,7 @@ function ToastSurface(props: {
<text fg={theme.text.default} wrapMode="word" flexGrow={1}>
{props.toast.message}
</text>
<Show when={props.toast.action || hovered()}>
<text
flexShrink={0}
marginLeft={2}
wrapMode="none"
attributes={hovered() ? TextAttributes.BOLD : undefined}
fg={hovered() ? theme.text.action.primary.default : theme.text.subdued}
>
{hovered() && props.toast.action ? " " : ""}
{props.toast.action?.label ?? "x"}
</text>
</Show>
{affordance()}
</box>
}
>
@@ -83,18 +84,7 @@ function ToastSurface(props: {
{props.toast.title}
</text>
<box flexGrow={1} />
<Show when={props.toast.action || hovered()}>
<text
flexShrink={0}
marginLeft={2}
wrapMode="none"
attributes={hovered() ? TextAttributes.BOLD : undefined}
fg={hovered() ? theme.text.action.primary.default : theme.text.subdued}
>
{hovered() && props.toast.action ? " " : ""}
{props.toast.action?.label ?? "x"}
</text>
</Show>
{affordance()}
</box>
<text fg={theme.text.default} wrapMode="word" width="100%">
{props.toast.message}
@@ -153,11 +143,15 @@ function init() {
const dismiss = () => {
clear()
paused = false
const next = store.queue[0]
setStore("queue", (queue) => queue.slice(1))
setStore("currentToast", next ?? null)
if (next) start(next.duration)
if (!next) {
paused = false
return
}
remaining = next.duration
if (!paused) start(next.duration)
}
const toast = {
+11 -4
View File
@@ -12,7 +12,7 @@ function captureToast(setToast: (toast: ToastContext) => void) {
}
}
test("clicking runs an action and advances a queued toast", async () => {
test("activation runs an action and keeps a queued toast paused", async () => {
let toast: ToastContext | undefined
const Capture = captureToast((value) => (toast = value))
const app = await testRender(() => (
@@ -30,7 +30,7 @@ test("clicking runs an action and advances a queued toast", async () => {
action: { label: "Open plugins", run: () => (activated = true) },
})
toast!.pause()
toast!.show({ message: "Copied", variant: "success" })
toast!.show({ message: "Copied", variant: "success", duration: 5 })
expect(toast!.currentToast?.message).toBe("Plugin failed")
expect(toast!.pending).toBe(1)
@@ -40,12 +40,19 @@ test("clicking runs an action and advances a queued toast", async () => {
expect(activated).toBe(true)
expect(toast!.currentToast?.message).toBe("Copied")
expect(toast!.pending).toBe(0)
await Bun.sleep(10)
expect(toast!.currentToast?.message).toBe("Copied")
toast!.resume()
await Bun.sleep(10)
expect(toast!.currentToast).toBeNull()
} finally {
app.renderer.destroy()
}
})
test("clicking a toast without an action dismisses it", async () => {
test("activation dismisses a toast without an action", async () => {
let toast: ToastContext | undefined
const Capture = captureToast((value) => (toast = value))
const app = await testRender(() => (
@@ -56,7 +63,7 @@ test("clicking a toast without an action dismisses it", async () => {
try {
await app.waitFor(() => toast !== undefined)
toast!.show({ message: "Copied", variant: "success" })
toast!.show({ message: "Copied", variant: "success", duration: 5 })
toast!.activate()
expect(toast!.currentToast).toBeNull()
} finally {
@@ -35,6 +35,7 @@ async function renderSessionTabs(
persisted?: string[]
sessionGate?: Promise<void>
sessionDirectories?: Record<string, string>
newLocation?: "launch" | "inherit"
},
) {
const temporary = options?.state ? undefined : await tmpdir()
@@ -106,7 +107,12 @@ async function renderSessionTabs(
<TestTuiContexts paths={{ state }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ConfigProvider
config={createTuiResolvedConfig({
tabs: { enabled: true },
session: { new_location: options?.newLocation ?? "launch" },
})}
>
<RouteProvider
initialRoute={options?.home ? { type: "home" } : { type: "session", sessionID: initialSessionID }}
>
@@ -319,8 +325,8 @@ test("tracks a temporary new session tab across close and creation", async () =>
}
})
test("add opens the new session tab carrying the current session's location", async () => {
const setup = await renderSessionTabs("first")
test("add opens the new session tab in the launch directory by default", async () => {
const setup = await renderSessionTabs("first", { sessionDirectories: { first: `${directory}/worktree` } })
try {
await wait(() => setup.tabs.current() === "first" && setup.data.session.get("first") !== undefined)
@@ -332,3 +338,19 @@ test("add opens the new session tab carrying the current session's location", as
await setup.destroy()
}
})
test("add inherits the current session location when configured", async () => {
const worktree = `${directory}/worktree`
const setup = await renderSessionTabs("first", {
newLocation: "inherit",
sessionDirectories: { first: worktree },
})
try {
await wait(() => setup.tabs.current() === "first" && setup.data.session.get("first") !== undefined)
setup.tabs.add()
expect(setup.route.data).toEqual({ type: "home", location: { directory: worktree } })
} finally {
await setup.destroy()
}
})
@@ -17,3 +17,14 @@ test("inherits the active session location when configured", () => {
test("falls back to the launch directory without an active session", () => {
expect(newSessionLocation("inherit", "/launch")).toEqual({ directory: "/launch" })
})
test("does not inherit an unavailable active location", () => {
expect(
newSessionLocation(
"inherit",
"/launch",
{ directory: "/deleted", workspaceID: "work-1" },
{ directory: "/deleted", workspaceID: "work-1" },
),
).toEqual({ directory: "/launch" })
})
+2
View File
@@ -1,6 +1,8 @@
## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for component defaults, visible copy, placeholders, accessible labels, tooltips, dialogs, toasts, empty states, and displayed errors.
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
- Render count-sensitive copy through `plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `t(...)`; `pluralForm(...)` is reserved for components that animate individual grammatical forms.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
+1
View File
@@ -10,6 +10,7 @@ const icons = {
prompt: `<path d="M14.5841 12.0807H17.9193V2.91406H5.6276V6.2474M14.5859 6.2474H2.08594V15.4141H5.0026V17.4974L8.7526 15.4141H14.5859V6.2474Z" stroke="currentColor" stroke-linecap="square"/>`,
brain: `<path d="M13.332 8.7487C11.4911 8.7487 9.9987 7.25631 9.9987 5.41536M6.66536 11.2487C8.50631 11.2487 9.9987 12.7411 9.9987 14.582M9.9987 2.78209L9.9987 17.0658M16.004 15.0475C17.1255 14.5876 17.9154 13.4849 17.9154 12.1978C17.9154 11.3363 17.5615 10.5575 16.9913 9.9987C17.5615 9.43991 17.9154 8.66108 17.9154 7.79962C17.9154 6.21199 16.7136 4.90504 15.1702 4.73878C14.7858 3.21216 13.4039 2.08203 11.758 2.08203C11.1171 2.08203 10.5162 2.25337 9.9987 2.55275C9.48117 2.25337 8.88032 2.08203 8.23944 2.08203C6.59353 2.08203 5.21157 3.21216 4.82722 4.73878C3.28377 4.90504 2.08203 6.21199 2.08203 7.79962C2.08203 8.66108 2.43585 9.43991 3.00609 9.9987C2.43585 10.5575 2.08203 11.3363 2.08203 12.1978C2.08203 13.4849 2.87191 14.5876 3.99339 15.0475C4.46688 16.7033 5.9917 17.9154 7.79962 17.9154C8.61335 17.9154 9.36972 17.6698 9.9987 17.2488C10.6277 17.6698 11.384 17.9154 12.1978 17.9154C14.0057 17.9154 15.5305 16.7033 16.004 15.0475Z" stroke="currentColor"/>`,
fork: `<path d="M2.91602 7.91406L2.91602 2.91406H7.91602M12.0827 2.91406H17.0827L17.0827 7.91406M9.99935 9.9974L9.99935 17.0807M9.99935 9.9974L3.33268 3.33073M9.99935 9.9974L16.666 3.33073" stroke="currentColor" stroke-linecap="square"/>`,
"workspace-isolated": `<g transform="translate(2 2)"><path d="M10.5 10.5V5.5H5.5V10.5H10.5Z" fill="currentColor"/><rect x="2.5" y="2.5" width="11" height="11" stroke="currentColor"/></g>`,
"bullet-list": `<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="currentColor" stroke-linecap="square"/>`,
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
+17 -14
View File
@@ -1,26 +1,24 @@
import { createContext, useContext, type Accessor, type ParentProps } from "solid-js"
import { I18nProvider } from "@kobalte/core/i18n"
import { dict as en } from "../i18n/en"
import type { Key, LocaleKey, PluralCategory, PluralKey, PluralLookupKey } from "../i18n/en"
export type UiI18nKey = keyof typeof en
export const UI_PLURAL_KEYS = [
"ui.sessionTurn.diffs.changed",
"ui.messagePart.context.read",
"ui.messagePart.context.search",
"ui.messagePart.context.list",
] as const
export type UiI18nPluralKey = (typeof UI_PLURAL_KEYS)[number]
export type UiPluralCategory = "zero" | "one" | "two" | "few" | "many" | "other"
export type UiI18nPluralLookupKey = `${UiI18nPluralKey}.${UiPluralCategory}`
export type UiI18nKey = Key
export type UiI18nPluralKey = PluralKey
export type UiPluralCategory = PluralCategory
export type UiI18nPluralLookupKey = PluralLookupKey
export type UiI18nLocaleKey = LocaleKey
type UiTranslationKey<Value extends string> = Value extends UiI18nPluralLookupKey ? never : Value
export type UiI18nParams = Record<string, string | number | boolean>
export type UiTranslate = <Value extends string>(key: UiTranslationKey<Value>, params?: UiI18nParams) => string
export type UiI18n = {
locale: Accessor<string>
layoutLocale?: Accessor<string>
t: (key: UiI18nKey, params?: UiI18nParams) => string
t: UiTranslate
plural: (key: UiI18nPluralKey, count: number, params?: UiI18nParams) => string
pluralForm?: (key: UiI18nPluralKey, category: UiPluralCategory, params?: UiI18nParams) => string
}
const rules = new Map<string, Intl.PluralRules>()
@@ -50,11 +48,16 @@ function resolveTemplate(text: string, params?: UiI18nParams) {
const fallback: UiI18n = {
locale: () => "en",
t: (key, params) => {
const value = en[key] ?? String(key)
const value = en[key as UiI18nKey] ?? String(key)
return resolveTemplate(value, params)
},
plural: (key, count, params) =>
fallback.t(pluralKey(key, pluralCategory(fallback.locale(), count)), { ...params, count }),
fallback.pluralForm!(key, pluralCategory(fallback.locale(), count), { ...params, count }),
pluralForm: (key, category, params) => {
const values = en as Partial<Record<UiI18nLocaleKey, string>>
const value = values[pluralKey(key, category)] ?? values[`${key}.other`] ?? `${key}.other`
return resolveTemplate(value, params)
},
}
const Context = createContext<UiI18n>(fallback)
+11 -2
View File
@@ -1,4 +1,4 @@
export const dict: Record<string, string> = {
const source = {
"ui.sessionReview.title": "Session changes",
"ui.sessionReview.title.git": "Git changes",
"ui.sessionReview.title.branch": "Branch changes",
@@ -215,4 +215,13 @@ export const dict: Record<string, string> = {
"ui.question.multiHint": "Select all answers that apply",
"ui.question.singleHint": "Select one answer",
"ui.question.custom.placeholder": "Type your answer...",
}
} satisfies Record<string, string>
export type Key = keyof typeof source
export type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other"
export type PluralKey = {
[Entry in Key]: Entry extends `${infer Base}.other` ? (`${Base}.one` extends Key ? Base : never) : never
}[Key]
export type PluralLookupKey = `${PluralKey}.${PluralCategory}`
export type LocaleKey = Key | PluralLookupKey
export const dict: typeof source & Record<string, string> = source
@@ -93,6 +93,9 @@
[data-slot="dialog-description"] {
flex: none;
flex-grow: 0;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
user-select: none;
font-weight: 440;
font-size: 13px;
+13
View File
@@ -1,5 +1,6 @@
import { onMount, type ComponentProps, splitProps } from "solid-js"
// Consumers center the SVG viewport, so each icon must center its artwork within its viewBox.
const icons = {
edit: {
viewBox: "0 0 16 16",
@@ -17,6 +18,10 @@ const icons = {
viewBox: "0 0 16 16",
body: `<path d="M5.118 5.686V10.314M5.118 5.686C5.97 5.686 6.661 4.995 6.661 4.143C6.661 3.291 5.97 2.6 5.118 2.6C4.266 2.6 3.575 3.291 3.575 4.143C3.575 4.995 4.266 5.686 5.118 5.686ZM5.118 10.314C4.266 10.314 3.575 11.005 3.575 11.857C3.575 12.709 4.266 13.4 5.118 13.4C5.97 13.4 6.661 12.709 6.661 11.857M5.118 10.314C5.97 10.314 6.661 11.005 6.661 11.857M10.882 5.686C11.734 5.686 12.425 4.995 12.425 4.143C12.425 3.291 11.734 2.6 10.882 2.6C10.03 2.6 9.339 3.291 9.339 4.143C9.339 4.995 10.03 5.686 10.882 5.686ZM10.882 5.686V9.457C10.882 10.783 9.807 11.857 8.482 11.857H6.661" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>`,
},
"branch-out": {
viewBox: "0 0 16 16",
body: `<path d="M10.4225 3.35355L12.9024 5.83344L10.4225 8.31333" stroke="currentColor"/><path d="M1 12.2852H4.23042C4.89912 12.2852 5.52359 11.951 5.89452 11.3946L9.00783 6.72462C8.37877 6.16823 10.0032 5.83402 10.6719 5.83402H12.9024" stroke="currentColor"/><path d="M8.5 12.2852H14" stroke="currentColor" stroke-linejoin="round"/>`,
},
"grid-plus": {
viewBox: "0 0 16 16",
body: `<path d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
@@ -121,6 +126,14 @@ const icons = {
viewBox: "0 0 20 20",
body: `<path d="M7 14.5H13M7 7.99512H10.0049M10.0049 7.99512H13M10.0049 7.99512V5M10.0049 7.99512V11M18 18V2L2 2L2 18H18Z" stroke="currentColor"/>`,
},
"window-analytics": {
viewBox: "0 0 16 16",
body: `<g transform="translate(1 2)"><path d="M7 4H11M7 8H11M0.5 0.5V11.5H13.5V0.5H0.5ZM3.5 3.5H4.5V4.5H3.5V3.5ZM3.5 7.5H4.5V8.5H3.5V7.5Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/></g>`,
},
trash: {
viewBox: "0 0 20 20",
body: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
},
"outline-sliders": {
viewBox: "0 0 16 16",
body: `<path d="M11.7779 4.66675H14.4446M11.7779 4.66675C11.7779 5.77132 10.8825 6.66675 9.77789 6.66675C8.67332 6.66675 7.77789 5.77132 7.77789 4.66675M11.7779 4.66675C11.7779 3.56218 10.8825 2.66675 9.77789 2.66675C8.67332 2.66675 7.77789 3.56218 7.77789 4.66675M1.55566 4.66675H7.77789M4.22233 11.3334H1.55566M4.22233 11.3334C4.22233 12.438 5.11776 13.3334 6.22233 13.3334C7.3269 13.3334 8.22233 12.438 8.22233 11.3334M4.22233 11.3334C4.22233 10.2288 5.11776 9.33341 6.22233 9.33341C7.3269 9.33341 8.22233 10.2288 8.22233 11.3334M14.4446 11.3334H8.22233" stroke="currentColor"/>`,
+3 -2
View File
@@ -32,6 +32,7 @@ export function TooltipV2(props: TooltipV2Props) {
])
const close = () => setState("open", false)
const controlled = () => local.forceOpen !== undefined
const inside = () => {
const active = document.activeElement
@@ -93,9 +94,9 @@ export function TooltipV2(props: TooltipV2Props) {
{...others}
closeDelay={0}
ignoreSafeArea={local.ignoreSafeArea ?? true}
open={local.forceOpen || state.open}
open={controlled() ? local.forceOpen : state.open}
onOpenChange={(open) => {
if (local.forceOpen) return
if (controlled()) return
if (state.block && open) return
if (justClickedTrigger) {
justClickedTrigger = false
+1 -2
View File
@@ -52,8 +52,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"resolve.exports": "catalog:",
"xdg-basedir": "5.1.0"
"resolve.exports": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
+10 -5
View File
@@ -1,14 +1,19 @@
import os from "os"
import path from "path"
import { xdgCache, xdgConfig, xdgData, xdgState } from "xdg-basedir"
const home = os.homedir()
const data = process.env.XDG_DATA_HOME || (home ? path.join(home, ".local", "share") : undefined)
const cache = process.env.XDG_CACHE_HOME || (home ? path.join(home, ".cache") : undefined)
const config = process.env.XDG_CONFIG_HOME || (home ? path.join(home, ".config") : undefined)
const state = process.env.XDG_STATE_HOME || (home ? path.join(home, ".local", "state") : undefined)
/** The XDG base directories that root opencode's global paths. */
export function roots(app: string) {
return {
data: path.join(xdgData!, app),
cache: path.join(xdgCache!, app),
config: path.join(xdgConfig!, app),
state: path.join(xdgState!, app),
data: path.join(data!, app),
cache: path.join(cache!, app),
config: path.join(config!, app),
state: path.join(state!, app),
tmp: path.join(os.tmpdir(), app),
}
}
+5 -4
View File
@@ -1,15 +1,16 @@
export * as NpmConfig from "./npm-config.js"
import { fileURLToPath } from "url"
// @ts-expect-error npm does not publish types for this internal config API.
import Config from "@npmcli/config"
// @ts-expect-error npm does not publish types for this internal config API.
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
import { Effect } from "effect"
export const load = (dir: string) =>
Effect.tryPromise({
try: async () => {
// @ts-expect-error npm does not publish types for this internal config API.
const { default: Config } = await import("@npmcli/config")
// @ts-expect-error npm does not publish types for this internal config API.
const { default: npmDefinitions } = await import("@npmcli/config/lib/definitions/index.js")
const { definitions, flatten, nerfDarts, shorthands } = npmDefinitions
const config = new Config({
// Resolved per call: on workerd import.meta.url is undefined and building
// this URL at module scope fails startup validation; npm config never runs there.
-67
View File
@@ -30,15 +30,6 @@ export interface Interface {
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly install: (
dir: string,
input?: {
add: {
name: string
version?: string
}[]
},
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
}
@@ -143,59 +134,6 @@ const layer = Layer.effect(
return resolveEntryPoint(first.name, first.path, options?.subpaths)
}, Effect.scoped)
const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
if (!canWrite) return
const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
if (
yield* Effect.gen(function* () {
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
if (!nodeModulesExists) {
yield* reify({ add, dir })
return true
}
return false
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
)
return
yield* Effect.gen(function* () {
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
const pkgAny = pkg as any
const lockAny = lock as any
const declared = new Set([
...Object.keys(pkgAny?.dependencies || {}),
...Object.keys(pkgAny?.devDependencies || {}),
...Object.keys(pkgAny?.peerDependencies || {}),
...Object.keys(pkgAny?.optionalDependencies || {}),
...(input?.add || []).map((pkg) => pkg.name),
])
const root = lockAny?.packages?.[""] || {}
const locked = new Set([
...Object.keys(root?.dependencies || {}),
...Object.keys(root?.devDependencies || {}),
...Object.keys(root?.peerDependencies || {}),
...Object.keys(root?.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
yield* reify({ dir, add })
return
}
}
}).pipe(Effect.withSpan("Npm.checkDirty"))
return
}, Effect.scoped)
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
@@ -249,7 +187,6 @@ const layer = Layer.effect(
return Service.of({
add,
install,
which,
})
}),
@@ -263,10 +200,6 @@ export const node = makeGlobalNode({
const { runPromise } = makeRuntime(Service, LayerNode.compile(node))
export async function install(...args: Parameters<Interface["install"]>) {
return runPromise((svc) => svc.install(...args))
}
export async function add(...args: Parameters<Interface["add"]>) {
return runPromise((svc) => svc.add(...args))
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test"
import os from "os"
import path from "path"
import { pathToFileURL } from "url"
const module = pathToFileURL(path.join(import.meta.dir, "../src/global-roots.ts")).href
describe("global roots", () => {
test("uses XDG overrides", () => {
const root = path.join(os.tmpdir(), "opencode-xdg-overrides")
const env = {
XDG_DATA_HOME: path.join(root, "data"),
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_STATE_HOME: path.join(root, "state"),
}
expect(run(env)).toEqual({
data: path.join(env.XDG_DATA_HOME, "opencode"),
cache: path.join(env.XDG_CACHE_HOME, "opencode"),
config: path.join(env.XDG_CONFIG_HOME, "opencode"),
state: path.join(env.XDG_STATE_HOME, "opencode"),
tmp: path.join(os.tmpdir(), "opencode"),
})
})
test("empty XDG overrides use home directory defaults", () => {
const home = path.join(os.tmpdir(), "opencode-xdg-home")
expect(
run({
XDG_DATA_HOME: "",
XDG_CACHE_HOME: "",
XDG_CONFIG_HOME: "",
XDG_STATE_HOME: "",
...(process.platform === "win32" ? { USERPROFILE: home } : { HOME: home }),
}),
).toEqual({
data: path.join(home, ".local", "share", "opencode"),
cache: path.join(home, ".cache", "opencode"),
config: path.join(home, ".config", "opencode"),
state: path.join(home, ".local", "state", "opencode"),
tmp: path.join(os.tmpdir(), "opencode"),
})
})
})
function run(env: Record<string, string>) {
const result = Bun.spawnSync({
cmd: [
process.execPath,
"-e",
`const { roots } = await import(${JSON.stringify(module)}); console.log(JSON.stringify(roots("opencode")))`,
],
env: { ...process.env, ...env },
stdout: "pipe",
stderr: "pipe",
})
expect(result.exitCode, result.stderr.toString()).toBe(0)
return JSON.parse(result.stdout.toString())
}
-1
View File
@@ -24,7 +24,6 @@
"@cloudflare/workers-types": "^4.20250808.0",
"vitest": "3.2.7",
"wrangler": "4.28.0",
"xdg-basedir": "5.1.0",
"unenv": "2.0.0-rc.24",
"@effect/platform-node": "catalog:"
}
+2 -4
View File
@@ -51,10 +51,8 @@ export default defineWorkersConfig({
// mime-types requires mime-db's JSON database at require time; keep the
// lookup surface but back it with a static shim.
{ find: /^mime-types$/, replacement: new URL("./test/shims/mime-types.mjs", import.meta.url).pathname },
// util/npm.ts imports the npm toolchain at module scope; plugin installs
// never happen in the workerd profile (plugin discovery is precompiled-only),
// and @npmcli/config touches process.stdout.isTTY during module init.
{ find: /^@npmcli\/config(\/.*)?$/, replacement: mockProxy },
// Plugin installs never happen in the workerd profile (plugin discovery
// is precompiled-only), so mock the package installation toolchain.
{ find: /^@npmcli\/arborist(\/.*)?$/, replacement: mockProxy },
{ find: /^pacote(\/.*)?$/, replacement: mockProxy },
],