mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05e9818285 | |||
| a5b6ead43e | |||
| 7a73aad0a2 | |||
| 304c85fb2b | |||
| 85988b957b | |||
| 146aeef830 | |||
| 28b630477d | |||
| 5cd7705036 | |||
| 5816fcbc61 | |||
| 655b3c4c22 | |||
| 8023ba378b |
@@ -1038,7 +1038,6 @@
|
|||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.5",
|
||||||
"npm-package-arg": "13.0.2",
|
"npm-package-arg": "13.0.2",
|
||||||
"resolve.exports": "catalog:",
|
"resolve.exports": "catalog:",
|
||||||
"xdg-basedir": "5.1.0",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
@@ -1104,7 +1103,6 @@
|
|||||||
"unenv": "2.0.0-rc.24",
|
"unenv": "2.0.0-rc.24",
|
||||||
"vitest": "3.2.7",
|
"vitest": "3.2.7",
|
||||||
"wrangler": "4.28.0",
|
"wrangler": "4.28.0",
|
||||||
"xdg-basedir": "5.1.0",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/www": {
|
"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-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=="],
|
"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=="],
|
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
## Localization
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -267,16 +267,34 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
}
|
}
|
||||||
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
|
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
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")
|
if (path === "/api/project/current")
|
||||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
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))
|
if (path === "/api/location") return json(route, location(config))
|
||||||
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
|
if (worktree && route.request().method() === "POST") {
|
||||||
if (projectCopy && route.request().method() === "POST") {
|
|
||||||
const input = route.request().postDataJSON() as { directory: string; name?: string }
|
const input = route.request().postDataJSON() as { directory: string; name?: string }
|
||||||
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
|
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": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
if (path === "/api/permission/request")
|
if (path === "/api/permission/request")
|
||||||
return json(route, {
|
return json(route, {
|
||||||
@@ -343,7 +361,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||||
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
||||||
const sessions = config.sessions
|
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) => parentID !== "null" || session.parentID === undefined)
|
||||||
.filter((session) => {
|
.filter((session) => {
|
||||||
const search = url.searchParams.get("search")?.toLowerCase()
|
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) {
|
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
||||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||||
|
const location = session.location && typeof session.location === "object" ? session.location : {}
|
||||||
return {
|
return {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
parentID: session.parentID,
|
parentID: session.parentID,
|
||||||
@@ -595,10 +617,19 @@ export function currentSession(session: { id: string } & Record<string, unknown>
|
|||||||
},
|
},
|
||||||
title: session.title ?? session.id,
|
title: session.title ?? session.id,
|
||||||
location: {
|
location: {
|
||||||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
directory:
|
||||||
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
"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,
|
revert: session.revert,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import * as Sentry from "@sentry/solid"
|
import * as Sentry from "@sentry/solid"
|
||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
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 { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||||
import { File } from "@opencode-ai/session-ui/file"
|
import { File } from "@opencode-ai/session-ui/file"
|
||||||
@@ -159,7 +160,13 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return (
|
return (
|
||||||
<I18nProvider
|
<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}
|
{props.children}
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type PromptInputV2ComposerProps = {
|
|||||||
class?: string
|
class?: string
|
||||||
controller: PromptInputV2ComposerController
|
controller: PromptInputV2ComposerController
|
||||||
borderUnderlay?: boolean
|
borderUnderlay?: boolean
|
||||||
|
accentSubmit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
||||||
@@ -53,6 +54,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
|||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<PromptInputV2
|
<PromptInputV2
|
||||||
controller={props.controller}
|
controller={props.controller}
|
||||||
|
accentSubmit={props.accentSubmit}
|
||||||
borderUnderlay={props.borderUnderlay}
|
borderUnderlay={props.borderUnderlay}
|
||||||
class={props.class}
|
class={props.class}
|
||||||
variantControlVisible={!props.controller.model.loading}
|
variantControlVisible={!props.controller.model.loading}
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { Prompt, PromptStore } from "@/context/prompt"
|
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
|
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||||
|
|
||||||
const createdClients: string[] = []
|
|
||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
const sessionCreateInputs: Array<{
|
type SessionCreateInput = {
|
||||||
agent?: string
|
agent?: string
|
||||||
model?: { id: string; providerID: string; variant?: string }
|
model?: { id: string; providerID: string; variant?: string }
|
||||||
location?: { directory: string }
|
location?: { directory: string }
|
||||||
}> = []
|
}
|
||||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
|
||||||
const optimistic: Array<{
|
const optimistic: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -22,11 +20,9 @@ const optimistic: Array<{
|
|||||||
variant?: string
|
variant?: string
|
||||||
}
|
}
|
||||||
}> = []
|
}> = []
|
||||||
const optimisticSeeded: boolean[] = []
|
|
||||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
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 sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||||
const syncedDirectories: string[] = []
|
const sentShellDirectories: string[] = []
|
||||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||||
const sentPrompts: string[] = []
|
const sentPrompts: string[] = []
|
||||||
const promptInputs: unknown[] = []
|
const promptInputs: unknown[] = []
|
||||||
@@ -37,15 +33,29 @@ const switchedModels: Array<{
|
|||||||
model: { id: string; providerID: string; variant?: string }
|
model: { id: string; providerID: string; variant?: string }
|
||||||
}> = []
|
}> = []
|
||||||
const sessionRequestOrder: 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 serverSessionSyncs = 0
|
||||||
|
|
||||||
let params: { id?: string } = {}
|
let params: { id?: string } = {}
|
||||||
let search: { draftId?: string } = {}
|
let search: { draftId?: string } = {}
|
||||||
let selected = "/repo/worktree-a"
|
let selected = "/repo/worktree-a"
|
||||||
let variant: string | undefined
|
let variant: string | undefined
|
||||||
let permissionServer = "server-a"
|
|
||||||
let createSessionGate: Promise<void> | undefined
|
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 }]
|
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||||
@@ -73,21 +83,25 @@ const prompt = {
|
|||||||
replaceComments: () => undefined,
|
replaceComments: () => undefined,
|
||||||
items: () => [],
|
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) => {
|
const clientFor = (directory: string) => {
|
||||||
createdClients.push(directory)
|
|
||||||
return {
|
return {
|
||||||
api: {
|
api: {
|
||||||
session: {
|
session: {
|
||||||
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
create: async (input: SessionCreateInput) => {
|
||||||
await createSessionGate
|
await createSessionGate
|
||||||
const location = input.location?.directory ?? directory
|
const location = input.location?.directory ?? directory
|
||||||
createdSessions.push(location)
|
createdSessions.push(location)
|
||||||
sessionCreateInputs.push(input)
|
const id = `session-${createdSessions.length}`
|
||||||
|
sessionDirectories[id] = location
|
||||||
return {
|
return {
|
||||||
id: `session-${createdSessions.length}`,
|
id,
|
||||||
projectID: "project",
|
projectID: "project",
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
@@ -100,7 +114,7 @@ const clientFor = (directory: string) => {
|
|||||||
},
|
},
|
||||||
prompt: async (input: unknown) => {
|
prompt: async (input: unknown) => {
|
||||||
sessionRequestOrder.push("prompt")
|
sessionRequestOrder.push("prompt")
|
||||||
sentPrompts.push(directory)
|
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
|
||||||
promptInputs.push(input)
|
promptInputs.push(input)
|
||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
},
|
},
|
||||||
@@ -120,6 +134,21 @@ const clientFor = (directory: string) => {
|
|||||||
},
|
},
|
||||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||||
sentShell.push(input)
|
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 }),
|
command: async () => ({ data: undefined }),
|
||||||
abort: 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", () => ({
|
mock.module("@opencode-ai/ui/toast", () => ({
|
||||||
Toast: { Region: () => null },
|
Toast: { Region: () => null },
|
||||||
|
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
|
||||||
showToast: () => 0,
|
showToast: () => 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -162,20 +189,13 @@ beforeAll(async () => {
|
|||||||
current: () => ({ name: "agent" }),
|
current: () => ({ name: "agent" }),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
promote(directory: string, sessionID: string) {
|
promote: () => undefined,
|
||||||
promoted.push({ directory, sessionID })
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/permission", () => {
|
mock.module("@/context/permission", () => {
|
||||||
const state = (server: string) => ({
|
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
|
||||||
enabledAutoAccept.push({ server, sessionID, directory })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
mock.module("@/context/server", () => ({
|
mock.module("@/context/server", () => ({
|
||||||
@@ -184,7 +204,10 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@/context/tabs", () => ({
|
mock.module("@/context/tabs", () => ({
|
||||||
useTabs: () => ({
|
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 }) => {
|
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
||||||
promotedDrafts.push({ draftID, ...session })
|
promotedDrafts.push({ draftID, ...session })
|
||||||
},
|
},
|
||||||
@@ -205,68 +228,70 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@/context/sdk", () => ({
|
mock.module("@/context/sdk", () => ({
|
||||||
useSDK: () => {
|
useSDK: () => {
|
||||||
const sdk = {
|
return () => ({
|
||||||
scope: "local",
|
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
||||||
directory: "/repo/main",
|
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
|
||||||
api: rootClient.api,
|
api: rootClient.api,
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
}
|
})
|
||||||
return () => sdk
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
useSync: () => () => ({
|
useSync: () => () => {
|
||||||
data: { command: commands },
|
const server = activeDirectorySync
|
||||||
session: {
|
return {
|
||||||
optimistic: {
|
data: { command: commands, project: "project" },
|
||||||
add: (value: {
|
session: {
|
||||||
directory?: string
|
optimistic: {
|
||||||
sessionID?: string
|
add: (value: {
|
||||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
directory?: string
|
||||||
}) => {
|
sessionID?: string
|
||||||
optimistic.push(value)
|
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||||
optimisticSeeded.push(
|
}) => {
|
||||||
!!value.directory &&
|
optimisticServers.push(server)
|
||||||
!!value.sessionID &&
|
optimistic.push(value)
|
||||||
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
|
},
|
||||||
)
|
remove: () => undefined,
|
||||||
},
|
},
|
||||||
remove: () => undefined,
|
|
||||||
},
|
},
|
||||||
},
|
set: () => undefined,
|
||||||
set: () => undefined,
|
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
|
||||||
}),
|
}
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/server-sync", () => ({
|
mock.module("@/context/server-sync", () => ({
|
||||||
useServerSync: () => () => ({
|
useServerSync: () => () => {
|
||||||
session: {
|
const server = activeServerSync
|
||||||
remember: () => undefined,
|
return {
|
||||||
set: () => undefined,
|
session: {
|
||||||
sync: async () => {
|
remember: () => undefined,
|
||||||
serverSessionSyncs++
|
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 }>
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
]
|
},
|
||||||
},
|
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", () => ({
|
mock.module("@/context/platform", () => ({
|
||||||
@@ -286,205 +311,141 @@ beforeAll(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createdClients.length = 0
|
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
sessionCreateInputs.length = 0
|
|
||||||
enabledAutoAccept.length = 0
|
|
||||||
optimistic.length = 0
|
optimistic.length = 0
|
||||||
optimisticSeeded.length = 0
|
|
||||||
promoted.length = 0
|
|
||||||
promotedDrafts.length = 0
|
promotedDrafts.length = 0
|
||||||
|
updatedDrafts.length = 0
|
||||||
|
sentCommands.length = 0
|
||||||
sentPrompts.length = 0
|
sentPrompts.length = 0
|
||||||
promptInputs.length = 0
|
promptInputs.length = 0
|
||||||
sentCommands.length = 0
|
|
||||||
switchedAgents.length = 0
|
switchedAgents.length = 0
|
||||||
switchedModels.length = 0
|
switchedModels.length = 0
|
||||||
sessionRequestOrder.length = 0
|
sessionRequestOrder.length = 0
|
||||||
commands.length = 0
|
syncedServers.length = 0
|
||||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
optimisticServers.length = 0
|
||||||
|
promptCaptures.length = 0
|
||||||
params = {}
|
params = {}
|
||||||
search = {}
|
search = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
syncedDirectories.length = 0
|
sentShellDirectories.length = 0
|
||||||
selected = "/repo/worktree-a"
|
selected = "/repo/worktree-a"
|
||||||
variant = undefined
|
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
|
createSessionGate = undefined
|
||||||
serverSessionSyncs = 0
|
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]
|
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", () => {
|
describe("prompt submit worktree selection", () => {
|
||||||
test("reads the latest worktree accessor value per submit", async () => {
|
test("admits only one concurrent new-workspace submission", async () => {
|
||||||
const submit = createPromptSubmit({
|
selected = "create"
|
||||||
prompt,
|
let release = () => {}
|
||||||
info: () => undefined,
|
createWorktreeGate = new Promise<void>((resolve) => {
|
||||||
imageAttachments: () => [],
|
release = resolve
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
|
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)
|
release()
|
||||||
selected = "/repo/worktree-b"
|
await Promise.all([first, duplicate])
|
||||||
await submit.handleSubmit(event)
|
expect(createdSessions).toEqual([worktreeDirectory])
|
||||||
|
await settle()
|
||||||
|
|
||||||
expect(createdClients).toEqual([])
|
expect(worktreeCreates).toBe(1)
|
||||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdSessions).toHaveLength(1)
|
||||||
expect(sessionCreateInputs).toEqual([
|
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||||
{
|
|
||||||
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"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies auto-accept to newly created sessions", async () => {
|
test("stops when the created workspace cannot initialize", async () => {
|
||||||
const submit = createPromptSubmit({
|
selected = "create"
|
||||||
prompt,
|
locationFailure = new Error("initialization failed")
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
await makeSubmit().handleSubmit(event)
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
expect(worktreeCreates).toBe(1)
|
||||||
|
expect(createdSessions).toEqual([])
|
||||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
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 = () => {}
|
let release = () => {}
|
||||||
createSessionGate = new Promise<void>((resolve) => {
|
createSessionGate = new Promise<void>((resolve) => {
|
||||||
release = resolve
|
release = resolve
|
||||||
})
|
})
|
||||||
const submit = createPromptSubmit({
|
let submitted = 0
|
||||||
prompt,
|
const submit = makeSubmit({
|
||||||
info: () => undefined,
|
onSubmit: () => submitted++,
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
const result = submit.handleSubmit(event)
|
||||||
permissionServer = "server-b"
|
activeSDK = "server-b"
|
||||||
|
activeServerSync = "server-b"
|
||||||
|
activeDirectorySync = "server-b"
|
||||||
|
search.draftId = "draft-2"
|
||||||
release()
|
release()
|
||||||
await result
|
await result
|
||||||
|
await settle()
|
||||||
|
|
||||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
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)
|
||||||
test("promotes drafts using the selected project's server", async () => {
|
expect(optimisticServers).toEqual(["server-a"])
|
||||||
search = { draftId: "draft-1" }
|
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||||
const submit = createPromptSubmit({
|
expect(submitted).toBe(0)
|
||||||
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" }])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("switches the selected agent and model before prompting", async () => {
|
test("switches the selected agent and model before prompting", async () => {
|
||||||
params = { id: "session-1" }
|
params = { id: "session-1" }
|
||||||
variant = "high"
|
variant = "high"
|
||||||
|
|
||||||
const submit = createPromptSubmit({
|
const submit = makeSubmit({
|
||||||
prompt,
|
|
||||||
info: () => ({
|
info: () => ({
|
||||||
id: "session-1",
|
id: "session-1",
|
||||||
agent: "old-agent",
|
agent: "old-agent",
|
||||||
model: { id: "old-model", providerID: "old-provider" },
|
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 submit.handleSubmit(event)
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
|
|
||||||
@@ -519,24 +480,12 @@ describe("prompt submit worktree selection", () => {
|
|||||||
commands.push({ name: "review" })
|
commands.push({ name: "review" })
|
||||||
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||||
|
|
||||||
const submit = createPromptSubmit({
|
const submit = makeSubmit({
|
||||||
prompt,
|
|
||||||
info: () => ({ id: "session-1" }),
|
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([
|
expect(sentCommands).toEqual([
|
||||||
{
|
{
|
||||||
@@ -552,66 +501,19 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(serverSessionSyncs).toBe(0)
|
expect(serverSessionSyncs).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses an injected model selection", async () => {
|
test("sends an initial shell after synchronous workspace creation", async () => {
|
||||||
params = { id: "session-1" }
|
selected = "create"
|
||||||
const model = {
|
const submit = makeSubmit({
|
||||||
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
mode: () => "shell",
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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 submit.handleSubmit(event)
|
||||||
|
await settle()
|
||||||
|
|
||||||
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
expect(sentShellDirectories).toEqual([worktreeDirectory])
|
||||||
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
expect(sentShell[0]).toMatchObject({
|
||||||
expect(optimisticSeeded).toEqual([true])
|
sessionID: "session-1",
|
||||||
|
command: "ls",
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt
|
|||||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||||
import { useSync, type DirectorySync } from "@/context/sync"
|
import { useSync, type DirectorySync } from "@/context/sync"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
|
||||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
import { setCursorPosition } from "./editor-dom"
|
import { setCursorPosition } from "./editor-dom"
|
||||||
@@ -25,12 +24,7 @@ import { createPromptSubmissionState } from "./submission-state"
|
|||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { blobDataUrl } from "@/utils/draft-store"
|
import { blobDataUrl } from "@/utils/draft-store"
|
||||||
|
|
||||||
type PendingPrompt = {
|
const submitting = new Set<string>()
|
||||||
abort: AbortController
|
|
||||||
cleanup: VoidFunction
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = new Map<string, PendingPrompt>()
|
|
||||||
|
|
||||||
export type FollowupDraft = {
|
export type FollowupDraft = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -50,7 +44,6 @@ type FollowupSendInput = {
|
|||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
messageID?: string
|
messageID?: string
|
||||||
optimisticBusy?: boolean
|
optimisticBusy?: boolean
|
||||||
before?: () => Promise<boolean> | boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
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" })
|
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 [head, ...tail] = text.split(" ")
|
||||||
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
||||||
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
||||||
setBusy()
|
setBusy()
|
||||||
try {
|
try {
|
||||||
if (!(await wait())) {
|
|
||||||
setIdle()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageID = Identifier.ascending("message")
|
const messageID = Identifier.ascending("message")
|
||||||
await input.api.command({
|
await input.api.command({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
@@ -159,14 +141,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!(await wait())) {
|
|
||||||
batch(() => {
|
|
||||||
setIdle()
|
|
||||||
remove()
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = input.session()
|
const session = input.session()
|
||||||
if (session?.agent !== input.draft.agent) {
|
if (session?.agent !== input.draft.agent) {
|
||||||
await input.api.switchAgent({ sessionID: input.draft.sessionID, 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 params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
const errorMessage = (err: unknown) => {
|
||||||
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||||
if (err && typeof err === "object" && "data" in err) {
|
if (err && typeof err === "object" && "data" in err) {
|
||||||
@@ -278,19 +250,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const abort = async () => {
|
const abort = async () => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return Promise.resolve()
|
if (!sessionID) return Promise.resolve()
|
||||||
|
|
||||||
serverSync().session.set("todo", sessionID, [])
|
serverSync().session.set("todo", sessionID, [])
|
||||||
|
|
||||||
input.onAbort?.()
|
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()
|
return sdk()
|
||||||
.api.session.interrupt({ sessionID })
|
.api.session.interrupt({ sessionID })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -319,9 +282,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seed = (dir: string, info: SessionInfo) => {
|
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
|
||||||
serverSync().session.remember(info)
|
target.session.remember(info)
|
||||||
const [, setStore] = serverSync().child(dir)
|
const [, setStore] = target.child(dir)
|
||||||
setStore("session", (list: SessionInfo[]) => {
|
setStore("session", (list: SessionInfo[]) => {
|
||||||
const result = Binary.search(list, info.id, (item) => item.id)
|
const result = Binary.search(list, info.id, (item) => item.id)
|
||||||
const next = [...list]
|
const next = [...list]
|
||||||
@@ -353,7 +316,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
if (input.working()) void abort()
|
if (input.working()) void abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelSelection = input.model ?? local.model
|
const modelSelection = input.model ?? local.model
|
||||||
const currentModel = modelSelection.current()
|
const currentModel = modelSelection.current()
|
||||||
const currentAgent = local.agent.current()
|
const currentAgent = local.agent.current()
|
||||||
@@ -366,283 +328,253 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
input.addToHistory(currentPrompt, mode)
|
const submissionSDK = sdk()
|
||||||
input.resetHistoryNavigation()
|
const submissionSync = sync()
|
||||||
|
const submissionServerSync = serverSync()
|
||||||
const projectDirectory = sdk().directory
|
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 permissionState = permission.currentServerState()
|
||||||
const isNewSession = !params.id
|
|
||||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
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
|
try {
|
||||||
if (isNewSession) {
|
input.addToHistory(currentPrompt, mode)
|
||||||
if (worktreeSelection === "create") {
|
input.resetHistoryNavigation()
|
||||||
const createdWorktree = await sdk()
|
|
||||||
.api.worktree.create({
|
let sessionDirectory = projectDirectory
|
||||||
projectID: sync().data.project,
|
if (isNewSession) {
|
||||||
strategy: "git",
|
if (worktreeSelection === "create") {
|
||||||
directory: getDirectory(projectDirectory),
|
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) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||||
description: errorMessage(err),
|
description: errorMessage(err),
|
||||||
})
|
})
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
if (!createdWorktree) return
|
if (created) {
|
||||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
seed(submissionServerSync, sessionDirectory, created)
|
||||||
sessionDirectory = createdWorktree.directory
|
session = created
|
||||||
}
|
await startTransition(() => {
|
||||||
|
if (!session) return
|
||||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
|
||||||
sessionDirectory = worktreeSelection
|
if (!draftID) resetWorktree?.()
|
||||||
}
|
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||||
|
localSession.promote(sessionDirectory, session.id, {
|
||||||
if (sessionDirectory !== projectDirectory) {
|
agent: currentAgent.name,
|
||||||
serverSync().child(sessionDirectory)
|
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||||
}
|
variant: variant ?? null,
|
||||||
|
})
|
||||||
input.onNewSessionWorktreeReset?.()
|
handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||||
}
|
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
|
||||||
|
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||||
let session = input.info()
|
submission.retarget(
|
||||||
if (!session && isNewSession) {
|
capturePrompt(
|
||||||
const created = await sdk()
|
{ dir: base64Encode(sessionDirectory), id: session.id },
|
||||||
.api.session.create({
|
{ server: draftServer, scope: submissionScope },
|
||||||
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),
|
|
||||||
})
|
})
|
||||||
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) {
|
||||||
if (!session) {
|
showToast({
|
||||||
showToast({
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
return
|
||||||
showToast({
|
}
|
||||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
|
||||||
restoreInput()
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text.startsWith("/")) {
|
const model = {
|
||||||
const [cmdName, ...args] = text.split(" ")
|
modelID: currentModel.id,
|
||||||
const commandName = cmdName.slice(1)
|
providerID: currentModel.provider.id,
|
||||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
}
|
||||||
if (customCommand) {
|
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()
|
clearInput()
|
||||||
const messageID = Identifier.ascending("message")
|
return
|
||||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
}
|
||||||
sdk()
|
|
||||||
.api.session.command({
|
if (!draftID || search.draftId === draftID) onSubmit?.()
|
||||||
|
|
||||||
|
if (mode === "shell") {
|
||||||
|
clearInput()
|
||||||
|
const eventID = Event.ID.create()
|
||||||
|
void submissionSDK.api.session
|
||||||
|
.shell({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: messageID,
|
id: eventID,
|
||||||
command: commandName,
|
command: text,
|
||||||
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) => {
|
.catch((err) => {
|
||||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
description: errorMessage(err),
|
||||||
})
|
})
|
||||||
restoreInput()
|
restoreInput()
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
if (text.startsWith("/")) {
|
||||||
const messageID = Identifier.ascending("message")
|
const [cmdName, ...args] = text.split(" ")
|
||||||
|
const commandName = cmdName.slice(1)
|
||||||
const removeOptimisticMessage = () => {
|
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
||||||
sync().session.optimistic.remove({
|
if (customCommand) {
|
||||||
directory: sessionDirectory,
|
clearInput()
|
||||||
sessionID: session.id,
|
const messageID = Identifier.ascending("message")
|
||||||
messageID,
|
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
||||||
})
|
void submissionSDK.api.session
|
||||||
}
|
.command({
|
||||||
|
sessionID: session.id,
|
||||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
id: messageID,
|
||||||
clearInput()
|
command: commandName,
|
||||||
|
arguments: args.join(" "),
|
||||||
const waitForWorktree = async () => {
|
agent,
|
||||||
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||||
if (!worktree || worktree.status !== "pending") return true
|
files: await Promise.all(
|
||||||
|
images.map(async (attachment) => ({
|
||||||
if (sessionDirectory === projectDirectory) {
|
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||||
sync().set("session_status", session.id, { type: "busy" })
|
name: attachment.filename,
|
||||||
}
|
})),
|
||||||
|
),
|
||||||
const controller = new AbortController()
|
})
|
||||||
const cleanup = () => {
|
.catch((err) => {
|
||||||
if (sessionDirectory === projectDirectory) {
|
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||||
sync().set("session_status", session.id, { type: "idle" })
|
showToast({
|
||||||
}
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
removeOptimisticMessage()
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
})
|
||||||
}
|
restoreInput()
|
||||||
|
})
|
||||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
|
||||||
|
|
||||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
|
||||||
if (controller.signal.aborted) {
|
|
||||||
resolve({ status: "failed", message: "aborted" })
|
|
||||||
return
|
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"),
|
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||||
description: errorMessage(err),
|
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()
|
} finally {
|
||||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
submitting.delete(submissionKey)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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 { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { sameDirectory } from "@/utils/workspace"
|
||||||
|
|
||||||
export function PromptWorkspaceSelector(props: {
|
export function PromptWorkspaceSelector(props: {
|
||||||
value: string
|
value: string
|
||||||
projectRoot: string
|
projectRoot: string
|
||||||
workspaces: string[]
|
workspaces: string[]
|
||||||
branch?: string
|
branch?: string
|
||||||
|
onboarding?: boolean
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
onDone: () => void
|
onDone: () => void
|
||||||
|
onViewAll: () => void
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
let pending: string | undefined
|
const [search, setSearch] = createSignal("")
|
||||||
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
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 = () => {
|
const icon = () => {
|
||||||
if (selected() === "main") return "monitor"
|
if (selected() === "main") return "monitor"
|
||||||
if (selected() === "create") return "workspace-new"
|
if (selected() === "create") return "workspace-new"
|
||||||
return "workspace"
|
return "workspace-isolated"
|
||||||
}
|
}
|
||||||
const select = (value: string) => {
|
const select = (value: string) => {
|
||||||
pending = value
|
pending = { type: "select", value }
|
||||||
}
|
}
|
||||||
const onOpenChange = (open: boolean) => {
|
const onOpenChange = (open: boolean) => {
|
||||||
if (open) return
|
if (open) {
|
||||||
const value = pending
|
setSearch("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const action = pending
|
||||||
pending = undefined
|
pending = undefined
|
||||||
if (value) props.onChange(value)
|
if (action?.type === "select") props.onChange(action.value)
|
||||||
|
if (action?.type === "viewAll") {
|
||||||
|
props.onViewAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
props.onDone()
|
props.onDone()
|
||||||
}
|
}
|
||||||
const label = () => {
|
const label = () => {
|
||||||
@@ -41,87 +58,214 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
<TooltipV2
|
||||||
<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">
|
placement="top"
|
||||||
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
openDelay={800}
|
||||||
<span class="min-w-0 truncate">{label()}</span>
|
value={
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
props.onboarding ? (
|
||||||
</MenuV2.Trigger>
|
<div class="flex flex-col gap-1 text-start">
|
||||||
<MenuV2.Portal>
|
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||||
<MenuV2.Content class="w-[180px]">
|
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||||
<MenuV2.Group>
|
<span>{language.t("workspace.onboarding.title")}</span>
|
||||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
</div>
|
||||||
<MenuV2.Item onSelect={() => select("main")}>
|
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
|
||||||
<IconV2 name="monitor" />
|
</div>
|
||||||
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
) : (
|
||||||
<Show when={selected() === "main"}>
|
language.t("session.new.workspace.trigger.tooltip")
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
)
|
||||||
</Show>
|
}
|
||||||
</MenuV2.Item>
|
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||||
<MenuV2.Item onSelect={() => select("create")}>
|
class="min-w-0"
|
||||||
<IconV2 name="workspace-new" />
|
>
|
||||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||||
<Show when={selected() === "create"}>
|
<MenuV2.Trigger
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||||
</Show>
|
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"
|
||||||
</MenuV2.Item>
|
>
|
||||||
</MenuV2.Group>
|
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
<Show when={props.workspaces.length > 0}>
|
<span class="min-w-0 truncate">{label()}</span>
|
||||||
<MenuV2.Separator />
|
<Show when={props.onboarding}>
|
||||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
<span
|
||||||
<MenuV2.SubTrigger>
|
data-slot="workspace-onboarding-dot"
|
||||||
<IconV2 name="workspace" />
|
aria-hidden="true"
|
||||||
{language.t("session.new.workspace.existing")}
|
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||||
</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>
|
|
||||||
</Show>
|
</Show>
|
||||||
</MenuV2.Content>
|
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
</MenuV2.Portal>
|
</MenuV2.Trigger>
|
||||||
</MenuV2>
|
<MenuV2.Portal>
|
||||||
<PromptGitStatus branch={props.branch} />
|
<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 language = useLanguage()
|
||||||
const label = () => {
|
const label = () => {
|
||||||
if (props.noGit) return language.t("session.new.git.none")
|
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
|
return props.branch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const icon = () => {
|
||||||
|
if (props.noGit) return "monitor"
|
||||||
|
if (props.from) return "branch-out"
|
||||||
|
return "branch"
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={label()}>
|
<Show when={label()}>
|
||||||
{(value) => (
|
{(value) => (
|
||||||
<>
|
<TooltipV2
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
placement="top"
|
||||||
<TooltipV2
|
value={value()}
|
||||||
placement="top"
|
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
|
||||||
value={value()}
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||||
class="min-w-0 max-w-[220px]"
|
>
|
||||||
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" />
|
||||||
<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]">
|
<span class="min-w-0 truncate">{value()}</span>
|
||||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
</div>
|
||||||
<span class="min-w-0 truncate">{value()}</span>
|
</TooltipV2>
|
||||||
</div>
|
|
||||||
</TooltipV2>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</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 { SettingsProvidersV2 } from "./providers"
|
||||||
import { SettingsModelsV2 } from "./models"
|
import { SettingsModelsV2 } from "./models"
|
||||||
import { SettingsServersV2 } from "./servers"
|
import { SettingsServersV2 } from "./servers"
|
||||||
|
import { SettingsWorkspacesV2 } from "./workspaces"
|
||||||
import { SettingsProjectsV2 } from "./projects"
|
import { SettingsProjectsV2 } from "./projects"
|
||||||
import { SettingsExtensionsV2 } from "./extensions"
|
import { SettingsExtensionsV2 } from "./extensions"
|
||||||
import { SettingsServerScope } from "../settings-server-picker"
|
import { SettingsServerScope } from "../settings-server-picker"
|
||||||
@@ -95,6 +96,10 @@ export const DialogSettings: Component<{
|
|||||||
<Icon name="folder" />
|
<Icon name="folder" />
|
||||||
{language.t("settings.tab.projects")}
|
{language.t("settings.tab.projects")}
|
||||||
</TabsV2.Trigger>
|
</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="workspaces">
|
||||||
|
<Icon name="workspace-isolated" />
|
||||||
|
{language.t("settings.tab.workspaces")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Group 3: Capabilities & Extensions */}
|
{/* Group 3: Capabilities & Extensions */}
|
||||||
@@ -140,6 +145,9 @@ export const DialogSettings: Component<{
|
|||||||
<SettingsProjectsV2 />
|
<SettingsProjectsV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<SettingsServerScope directory={directory()}>
|
<SettingsServerScope directory={directory()}>
|
||||||
|
<TabsV2.Content value="workspaces" class="settings-v2-panel">
|
||||||
|
<SettingsWorkspacesV2 activeDirectory={directory()} />
|
||||||
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||||
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
|
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useUpdaterAction } from "../updater-action"
|
import { useUpdaterAction } from "../updater-action"
|
||||||
import { useSettings } from "@/context/settings"
|
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
|
||||||
import { ExternalLink } from "../external-link"
|
import { ExternalLink } from "../external-link"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
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 ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const options = createMemo(() =>
|
const options = createMemo(() =>
|
||||||
@@ -300,6 +328,7 @@ export const SettingsGeneralV2: Component<{
|
|||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<LanguageSetting />
|
<LanguageSetting />
|
||||||
|
|
||||||
|
<WorkspaceDestinationSetting />
|
||||||
<PermissionScopeSetting controller={permissionScope} />
|
<PermissionScopeSetting controller={permissionScope} />
|
||||||
|
|
||||||
<ShellSetting controller={shell} />
|
<ShellSetting controller={shell} />
|
||||||
@@ -362,18 +391,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
||||||
|
|
||||||
<SettingsListV2>
|
<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
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.showSearch.title")}
|
title={language.t("settings.general.row.showSearch.title")}
|
||||||
description={language.t("settings.general.row.showSearch.description")}
|
description={language.t("settings.general.row.showSearch.description")}
|
||||||
|
|||||||
@@ -692,6 +692,223 @@
|
|||||||
color: var(--v2-text-text-base);
|
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"] {
|
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
||||||
width: 480px;
|
width: 480px;
|
||||||
max-width: calc(100vw - 32px);
|
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"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
type ProjectApi = ServerApi["project"]
|
type ProjectApi = ServerApi["project"]
|
||||||
|
type WorktreeApi = ServerApi["worktree"]
|
||||||
|
|
||||||
describe("query keys", () => {
|
describe("query keys", () => {
|
||||||
test("partitions identical directories by server scope", () => {
|
test("partitions identical directories by server scope", () => {
|
||||||
@@ -106,16 +107,58 @@ describe("query keys", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("loads projects from the current endpoint", async () => {
|
test("loads projects from the current endpoint", async () => {
|
||||||
const api = {
|
const calls: string[] = []
|
||||||
|
const projects = {
|
||||||
list: async () => [
|
list: async () => [
|
||||||
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||||
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||||
],
|
],
|
||||||
} as unknown as ProjectApi
|
} 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.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 () => {
|
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 { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
import { sameDirectory } from "@/utils/workspace"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -106,6 +107,7 @@ type ProjectApi = {
|
|||||||
readonly list: () => Promise<ProjectListOutput>
|
readonly list: () => Promise<ProjectListOutput>
|
||||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||||
}
|
}
|
||||||
|
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
||||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||||
|
|
||||||
type McpApi = ServerApi["mcp"]
|
type McpApi = ServerApi["mcp"]
|
||||||
@@ -113,15 +115,35 @@ type PermissionApi = ServerApi["permission"]
|
|||||||
type QuestionApi = ServerApi["question"]
|
type QuestionApi = ServerApi["question"]
|
||||||
type VcsApi = ServerApi["vcs"]
|
type VcsApi = ServerApi["vcs"]
|
||||||
|
|
||||||
export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, "project"],
|
queryKey: [scope, "project"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
api.list().then((projects) => {
|
projects.list().then(async (items) => {
|
||||||
return projects
|
return (
|
||||||
.filter((p) => !!p?.id)
|
await Promise.all(
|
||||||
.map(normalizeProjectInfo)
|
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"))
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
@@ -130,7 +152,11 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
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
|
scope: ServerScope
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => 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(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
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)),
|
.then((data) => input.setGlobalStore("project", data)),
|
||||||
]
|
]
|
||||||
await runAll(slow)
|
await runAll(slow)
|
||||||
|
|||||||
@@ -124,9 +124,11 @@ export function normalizeProviderList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||||
|
const worktree = "canonical" in project ? project.canonical : project.worktree
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
worktree: "canonical" in project ? project.canonical : project.worktree,
|
worktree,
|
||||||
|
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
|
||||||
vcs: project.vcs === "git" ? "git" : undefined,
|
vcs: project.vcs === "git" ? "git" : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import * as i18n from "@solid-primitives/i18n"
|
|||||||
import { createEffect, createMemo, createResource } from "solid-js"
|
import { createEffect, createMemo, createResource } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
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 { Persist, persisted } from "@/utils/persist"
|
||||||
import { dict as en } from "@/i18n/en"
|
import { dict as en } from "@/i18n/en"
|
||||||
import { dict as uiEn } from "@opencode-ai/ui/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 RawDictionary = typeof en & typeof uiEn
|
||||||
type Dictionary = i18n.Flatten<RawDictionary>
|
type Dictionary = i18n.Flatten<RawDictionary>
|
||||||
type PluralKey =
|
type AppI18nKey = Extract<keyof typeof en, string>
|
||||||
| UiI18nPluralKey
|
type AppI18nPluralKey = {
|
||||||
| "session.question.pending"
|
[Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never
|
||||||
| "session.followupDock.summary"
|
}[AppI18nKey]
|
||||||
| "session.revertDock.summary"
|
type PluralKey = AppI18nPluralKey | UiI18nPluralKey
|
||||||
| "session.background.shell"
|
type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}`
|
||||||
| "session.background.subagent"
|
type TranslationKey<Key extends Extract<keyof Dictionary, string>> = Key extends
|
||||||
|
| AppI18nPluralLookupKey
|
||||||
|
| UiI18nPluralLookupKey
|
||||||
|
? never
|
||||||
|
: Key
|
||||||
type Source = { dict: Record<string, string> }
|
type Source = { dict: Record<string, string> }
|
||||||
|
|
||||||
function cookie(locale: Locale) {
|
function cookie(locale: Locale) {
|
||||||
@@ -191,18 +200,25 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
initialValue: dicts.get(initial) ?? base,
|
initialValue: dicts.get(initial) ?? base,
|
||||||
})
|
})
|
||||||
|
|
||||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
|
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as <
|
||||||
key: keyof Dictionary,
|
Key extends Extract<keyof Dictionary, string>,
|
||||||
|
>(
|
||||||
|
key: TranslationKey<Key>,
|
||||||
params?: Record<string, string | number | boolean>,
|
params?: Record<string, string | number | boolean>,
|
||||||
) => string
|
) => string
|
||||||
|
|
||||||
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
|
const pluralForm = (
|
||||||
const category = pluralCategory(intl(), count)
|
key: PluralKey,
|
||||||
|
category: UiPluralCategory,
|
||||||
|
params?: Record<string, string | number | boolean>,
|
||||||
|
) => {
|
||||||
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
|
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
|
||||||
const candidate = `${key}.${category}`
|
const candidate = `${key}.${category}`
|
||||||
const fallback = `${key}.other`
|
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]
|
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value]
|
||||||
|
|
||||||
@@ -233,6 +249,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
label,
|
label,
|
||||||
t,
|
t,
|
||||||
plural,
|
plural,
|
||||||
|
pluralForm,
|
||||||
setLocale(next: Locale) {
|
setLocale(next: Locale) {
|
||||||
setStore("locale", normalizeLocale(next))
|
setStore("locale", normalizeLocale(next))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
|
|||||||
import { useSettings } from "./settings"
|
import { useSettings } from "./settings"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useTabs, type Tab } from "./tabs"
|
import { useTabs, type Tab } from "./tabs"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import {
|
import {
|
||||||
createPromptReady,
|
createPromptReady,
|
||||||
createPromptSession,
|
createPromptSession,
|
||||||
@@ -104,11 +105,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
||||||
const scope = (): PromptScope =>
|
const scope = (): PromptScope =>
|
||||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||||
const load = (scope: PromptScope) => {
|
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
|
||||||
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
const current = settings.general.newLayoutDesigns()
|
||||||
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
? 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)
|
const existing = cache.get(key)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
cache.delete(key)
|
cache.delete(key)
|
||||||
@@ -118,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const entry = createRoot(
|
const entry = createRoot(
|
||||||
(dispose) => ({
|
(dispose) => ({
|
||||||
value: createPromptSession(serverSDK().scope, scope),
|
value: createPromptSession(target?.scope ?? serverSDK().scope, scope),
|
||||||
dispose,
|
dispose,
|
||||||
}),
|
}),
|
||||||
owner,
|
owner,
|
||||||
@@ -130,7 +133,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = createMemo(() => load(scope()))
|
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 ready = createPromptReady(session)
|
||||||
|
|
||||||
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||||
@@ -146,7 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||||
|
pick(scope, target).capture(),
|
||||||
current: withSuspense(() => session().current()),
|
current: withSuspense(() => session().current()),
|
||||||
cursor: withSuspense(() => session().cursor()),
|
cursor: withSuspense(() => session().cursor()),
|
||||||
dirty: withSuspense(() => session().dirty()),
|
dirty: withSuspense(() => session().dirty()),
|
||||||
|
|||||||
@@ -541,6 +541,23 @@ describe("server session", () => {
|
|||||||
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
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 () => {
|
test("loads session content through the server client", async () => {
|
||||||
const ctx = setup({ root: session("root") })
|
const ctx = setup({ root: session("root") })
|
||||||
|
|
||||||
@@ -1184,6 +1201,16 @@ describe("server session", () => {
|
|||||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
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", () => {
|
test("does not remove content confirmed by a message event", () => {
|
||||||
const message = userMessage("message")
|
const message = userMessage("message")
|
||||||
const part = textPart(message.id)
|
const part = textPart(message.id)
|
||||||
|
|||||||
@@ -257,7 +257,13 @@ export function createServerSession(
|
|||||||
const indexProjectedMessage = (message: Message) => {
|
const indexProjectedMessage = (message: Message) => {
|
||||||
const current = data.session_message[message.sessionID] ?? []
|
const current = data.session_message[message.sessionID] ?? []
|
||||||
if (current.some((item) => item.id === message.id)) return
|
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) => {
|
const remember = (session: SessionInfo) => {
|
||||||
@@ -1443,6 +1449,7 @@ export function createServerSession(
|
|||||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||||
if (!items)
|
if (!items)
|
||||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
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("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
|
||||||
setData(
|
setData(
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
@@ -1476,6 +1483,10 @@ export function createServerSession(
|
|||||||
)
|
)
|
||||||
return
|
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("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
loadMcpResourcesQuery,
|
loadMcpResourcesQuery,
|
||||||
reconcileActiveSessionStatuses,
|
reconcileActiveSessionStatuses,
|
||||||
seedActiveSessionStatuses,
|
seedActiveSessionStatuses,
|
||||||
|
shouldRefreshWorkspaceSessions,
|
||||||
} from "./server-sync"
|
} from "./server-sync"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import { createServerSession } from "./server-session"
|
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", () => {
|
describe("canDisposeDirectory", () => {
|
||||||
test("rejects pinned or inflight directories", () => {
|
test("rejects pinned or inflight directories", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -58,6 +58,17 @@ import { createCatalogSync } from "./server-sync/catalog"
|
|||||||
import { createConnectionSync } from "./server-sync/connection"
|
import { createConnectionSync } from "./server-sync/connection"
|
||||||
import { usePlatform } from "./platform"
|
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 = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
error?: InitError
|
error?: InitError
|
||||||
@@ -185,7 +196,7 @@ export function reconcileActiveSessionStatuses(
|
|||||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
projects: () => loadProjectsQuery(scope, serverAPI.project, serverAPI.worktree),
|
||||||
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
||||||
integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration),
|
integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration),
|
||||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
||||||
@@ -553,12 +564,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
}
|
}
|
||||||
const toDirectoryEvent = (event: ServerEvent) => {
|
const toDirectoryEvent = (event: ServerEvent) => {
|
||||||
if (event.current?.type === "session.created") return
|
if (event.current?.type === "session.created") return
|
||||||
if (
|
if (event.current?.type !== "session.renamed" && event.current?.type !== "session.usage.updated") return event
|
||||||
event.current?.type !== "session.renamed" &&
|
|
||||||
event.current?.type !== "session.moved" &&
|
|
||||||
event.current?.type !== "session.usage.updated"
|
|
||||||
)
|
|
||||||
return event
|
|
||||||
const info = session.get(event.current.data.sessionID)
|
const info = session.get(event.current.data.sessionID)
|
||||||
if (info) return { type: "session.updated", properties: { info } }
|
if (info) return { type: "session.updated", properties: { info } }
|
||||||
return event
|
return event
|
||||||
@@ -576,6 +582,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
markSessionListChanged(event, directory, previousDirectory)
|
markSessionListChanged(event, directory, previousDirectory)
|
||||||
if (event.current) session.applyV2(event.current)
|
if (event.current) session.applyV2(event.current)
|
||||||
session.apply(event)
|
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")
|
if (event.current?.type === "session.created")
|
||||||
void session
|
void session
|
||||||
.resolve(event.current.data.sessionID, { force: true })
|
.resolve(event.current.data.sessionID, { force: true })
|
||||||
@@ -630,10 +646,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
return
|
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")
|
if (event.current?.type === "session.forked")
|
||||||
void session
|
void session
|
||||||
.resolve(event.current.data.sessionID, { force: true })
|
.resolve(event.current.data.sessionID, { force: true })
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { createStore, reconcile } from "solid-js/store"
|
|||||||
import { createEffect, createMemo } from "solid-js"
|
import { createEffect, createMemo } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { persisted } from "@/utils/persist"
|
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 {
|
export interface NotificationSettings {
|
||||||
agent: boolean
|
agent: boolean
|
||||||
@@ -44,6 +48,10 @@ export interface Settings {
|
|||||||
permissions: {
|
permissions: {
|
||||||
autoApprove: boolean
|
autoApprove: boolean
|
||||||
}
|
}
|
||||||
|
workspaces: {
|
||||||
|
defaultDestination: WorkspaceDefaultDestination
|
||||||
|
lastUsed: Record<string, WorkspaceLastUsed>
|
||||||
|
}
|
||||||
notifications: NotificationSettings
|
notifications: NotificationSettings
|
||||||
sounds: SoundSettings
|
sounds: SoundSettings
|
||||||
}
|
}
|
||||||
@@ -126,6 +134,10 @@ const defaultSettings: Settings = {
|
|||||||
permissions: {
|
permissions: {
|
||||||
autoApprove: false,
|
autoApprove: false,
|
||||||
},
|
},
|
||||||
|
workspaces: {
|
||||||
|
defaultDestination: "last-used",
|
||||||
|
lastUsed: {},
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
agent: true,
|
agent: true,
|
||||||
permissions: true,
|
permissions: true,
|
||||||
@@ -291,6 +303,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setStore("permissions", "autoApprove", value)
|
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: {
|
notifications: {
|
||||||
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
|
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
|
||||||
setAgent(value: boolean) {
|
setAgent(value: boolean) {
|
||||||
|
|||||||
@@ -1166,6 +1166,47 @@ export const dict = {
|
|||||||
"session.delete.button": "Delete session",
|
"session.delete.button": "Delete session",
|
||||||
|
|
||||||
"workspace.new": "New workspace",
|
"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.local": "local",
|
||||||
"workspace.type.sandbox": "sandbox",
|
"workspace.type.sandbox": "sandbox",
|
||||||
"workspace.create.failed.title": "Failed to create workspace",
|
"workspace.create.failed.title": "Failed to create workspace",
|
||||||
|
|||||||
@@ -327,4 +327,9 @@
|
|||||||
animation-range: 0 0.1px;
|
animation-range: 0 0.1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-new-layout] [data-slot="session-turn-diffs-header"] {
|
||||||
|
height: 24px;
|
||||||
|
padding-block: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createPromptProjectController } from "@/components/prompt-project-selector"
|
import { createPromptProjectController } from "@/components/prompt-project-selector"
|
||||||
|
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||||
import { useSettings } from "@/context/settings"
|
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 { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
|
||||||
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
|
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
|
||||||
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
|
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() {
|
export default function NewSessionPage() {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const rightMount = useTitlebarRightMount()
|
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({
|
const draft = createNewSessionDraftController({
|
||||||
worktree: workspace.selection.value,
|
worktree: workspace.selection.value,
|
||||||
resetWorktree: workspace.selection.reset,
|
resetWorktree: workspace.selection.reset,
|
||||||
|
onSubmit: workspace.selection.remember,
|
||||||
})
|
})
|
||||||
const project = createPromptProjectController({
|
const project = createPromptProjectController({
|
||||||
controls: draft.project.controls,
|
controls: draft.project.controls,
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import { createPromptModelSelection } from "@/pages/session/composer/prompt-mode
|
|||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
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 prompt = usePrompt()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
@@ -36,7 +40,10 @@ export function createNewSessionDraftController(workspace: { worktree: () => str
|
|||||||
return workspace.worktree()
|
return workspace.worktree()
|
||||||
},
|
},
|
||||||
onNewSessionWorktreeReset: workspace.resetWorktree,
|
onNewSessionWorktreeReset: workspace.resetWorktree,
|
||||||
onSubmit: comments.clear,
|
onSubmit: () => {
|
||||||
|
workspace.onSubmit()
|
||||||
|
comments.clear()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
||||||
import { Show, createMemo, createSignal } from "solid-js"
|
import { Show, createMemo, createSignal } from "solid-js"
|
||||||
@@ -31,6 +31,15 @@ export function NewSessionView(props: {
|
|||||||
project: PromptProjectController
|
project: PromptProjectController
|
||||||
workspace: NewSessionWorkspaceController
|
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 (
|
return (
|
||||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||||
<div
|
<div
|
||||||
@@ -41,7 +50,7 @@ export function NewSessionView(props: {
|
|||||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||||
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
||||||
<div class="mt-8 flex flex-col gap-8">
|
<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()}>
|
<Show when={props.project.empty()}>
|
||||||
<PromptProjectAddButton controller={props.project} />
|
<PromptProjectAddButton controller={props.project} />
|
||||||
</Show>
|
</Show>
|
||||||
@@ -59,8 +68,10 @@ export function NewSessionView(props: {
|
|||||||
projectRoot={props.workspace.project.root()}
|
projectRoot={props.workspace.project.root()}
|
||||||
workspaces={props.workspace.project.workspaces()}
|
workspaces={props.workspace.project.workspaces()}
|
||||||
branch={props.workspace.bar.branch()}
|
branch={props.workspace.bar.branch()}
|
||||||
onChange={props.workspace.selection.set}
|
onboarding={onboardingReady() && !onboarding.used}
|
||||||
|
onChange={select}
|
||||||
onDone={props.input.restoreFocus}
|
onDone={props.input.restoreFocus}
|
||||||
|
onViewAll={props.workspace.project.openAll}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +148,7 @@ function ProviderTip() {
|
|||||||
>
|
>
|
||||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||||
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
@@ -152,7 +163,7 @@ function ProviderTip() {
|
|||||||
aria-label={language.t("common.dismiss")}
|
aria-label={language.t("common.dismiss")}
|
||||||
onClick={() => setPersistedState("dismissedAt", Date.now())}
|
onClick={() => setPersistedState("dismissedAt", Date.now())}
|
||||||
>
|
>
|
||||||
<IconV2 name="xmark-small" />
|
<Icon name="xmark-small" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ describe("new session workspace selection", () => {
|
|||||||
expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main")
|
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", () => {
|
test("falls back to the local branch for main, create, and unknown worktrees", () => {
|
||||||
const branch = (worktree: string) => (worktree === "/project/feature" ? "feature" : undefined)
|
const branch = (worktree: string) => (worktree === "/project/feature" ? "feature" : undefined)
|
||||||
expect(resolveNewSessionBranch({ worktree: "main", local: "dev", worktreeBranch: branch })).toBe("dev")
|
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 { useSDK } from "@/context/sdk"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import {
|
||||||
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
isWorkspaceDirectory,
|
||||||
|
isWorkspaceSelection,
|
||||||
|
sameDirectory,
|
||||||
|
workspaceDefaultSelection,
|
||||||
|
workspaceDirectories,
|
||||||
|
} from "@/utils/workspace"
|
||||||
|
|
||||||
export function resolveNewSessionWorktree(input: {
|
export function resolveNewSessionWorktree(input: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
selected?: string
|
selected?: string
|
||||||
directory: string
|
directory: string
|
||||||
projectWorktree?: string
|
projectWorktree?: string
|
||||||
|
fallback?: string
|
||||||
}) {
|
}) {
|
||||||
if (!input.enabled) return "main"
|
if (!input.enabled) return "main"
|
||||||
if (input.selected) return input.selected
|
if (input.selected) return input.selected
|
||||||
if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory
|
if (input.projectWorktree && !sameDirectory(input.directory, input.projectWorktree)) return input.directory
|
||||||
return "main"
|
return input.fallback ?? "main"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
|
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
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,18 +39,38 @@ export function resolveNewSessionBranch(input: {
|
|||||||
return input.worktreeBranch(input.worktree) ?? input.local
|
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 sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const [worktree, setWorktree] = createSignal<string>()
|
const settings = useSettings()
|
||||||
const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
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(() =>
|
const value = createMemo(() =>
|
||||||
resolveNewSessionWorktree({
|
resolveNewSessionWorktree({
|
||||||
enabled: visible(),
|
enabled: visible(),
|
||||||
selected: worktree(),
|
selected: selected(),
|
||||||
directory: sdk().directory,
|
directory: sdk().directory,
|
||||||
projectWorktree: sync().project?.worktree,
|
projectWorktree: sync().project?.worktree,
|
||||||
|
fallback: fallback(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||||
@@ -54,18 +82,36 @@ export function createNewSessionWorkspaceController() {
|
|||||||
worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
|
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 {
|
return {
|
||||||
selection: {
|
selection: {
|
||||||
value,
|
value,
|
||||||
reset: () => setWorktree(),
|
workspace: createMemo(() => {
|
||||||
set: (worktree: string) =>
|
const project = sync().project
|
||||||
setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)),
|
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: {
|
project: {
|
||||||
root: projectRoot,
|
root: projectRoot,
|
||||||
workspaces: () => sync().project?.sandboxes ?? [],
|
workspaces: () => {
|
||||||
|
const project = sync().project
|
||||||
|
return project ? workspaceDirectories(project) : []
|
||||||
|
},
|
||||||
git: () => sync().project?.vcs === "git",
|
git: () => sync().project?.vcs === "git",
|
||||||
|
openAll: input.onViewAll,
|
||||||
},
|
},
|
||||||
bar: {
|
bar: {
|
||||||
visible,
|
visible,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
|||||||
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { isWorkspaceDirectory } from "@/utils/workspace"
|
||||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||||
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
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()
|
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 timeline = createTimelineModel({ session: controller })
|
||||||
const historyLoading = timeline.history.loading
|
const historyLoading = timeline.history.loading
|
||||||
const historyMore = timeline.history.more
|
const historyMore = timeline.history.more
|
||||||
@@ -572,6 +575,7 @@ export default function Page() {
|
|||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
...sessionViewState(),
|
...sessionViewState(),
|
||||||
newSessionWorktree: "main",
|
newSessionWorktree: "main",
|
||||||
|
sessionDetailsOpen: false,
|
||||||
deferRender: false,
|
deferRender: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -676,8 +680,23 @@ export default function Page() {
|
|||||||
: skipToken,
|
: 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(() => {
|
const refreshVcs = debounce(() => {
|
||||||
void queryClient.invalidateQueries({ queryKey: vcsKey() })
|
void queryClient.invalidateQueries({ queryKey: vcsKey() })
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [serverSDK().scope, "session-details", sessionDirectory()] })
|
||||||
}, 100)
|
}, 100)
|
||||||
onCleanup(
|
onCleanup(
|
||||||
sdk().event.listen((event) => {
|
sdk().event.listen((event) => {
|
||||||
@@ -1687,7 +1706,6 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
||||||
|
|
||||||
const queuedFollowups = createMemo(() => {
|
const queuedFollowups = createMemo(() => {
|
||||||
const id = controller.identity.params.id
|
const id = controller.identity.params.id
|
||||||
if (!id) return emptyFollowups
|
if (!id) return emptyFollowups
|
||||||
@@ -1700,6 +1718,12 @@ export default function Page() {
|
|||||||
return followup.edit[id]
|
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(() => ({
|
const followupMutation = useMutation(() => ({
|
||||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
||||||
const owner = controller.ownership.capture()
|
const owner = controller.ownership.capture()
|
||||||
@@ -2036,7 +2060,7 @@ export default function Page() {
|
|||||||
>
|
>
|
||||||
{hasReview()
|
{hasReview()
|
||||||
? language.t("session.review.filesChanged", { count: reviewCount() })
|
? language.t("session.review.filesChanged", { count: reviewCount() })
|
||||||
: language.t("session.review.change.other")}
|
: language.plural("session.review.change", 0)}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -2102,6 +2126,10 @@ export default function Page() {
|
|||||||
if (root) scheduleScrollState(root)
|
if (root) scheduleScrollState(root)
|
||||||
}}
|
}}
|
||||||
userMessages={visibleUserMessages()}
|
userMessages={visibleUserMessages()}
|
||||||
|
diffs={sessionDetailsDiffs}
|
||||||
|
onReview={openReviewPanel}
|
||||||
|
workspaceMoveEligible={workspaceMoveEligible()}
|
||||||
|
onSummaryOpenChange={(open) => setStore("sessionDetailsOpen", open)}
|
||||||
setHistoryAnchor={(handlers) => {
|
setHistoryAnchor={(handlers) => {
|
||||||
captureHistoryAnchor = handlers.capture
|
captureHistoryAnchor = handlers.capture
|
||||||
restoreHistoryAnchor = handlers.restore
|
restoreHistoryAnchor = handlers.restore
|
||||||
@@ -2229,7 +2257,13 @@ export default function Page() {
|
|||||||
setFollowup("paused", id, true)
|
setFollowup("paused", id, true)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return <PromptInputV2Composer controller={promptInputController} borderUnderlay />
|
return (
|
||||||
|
<PromptInputV2Composer
|
||||||
|
controller={promptInputController}
|
||||||
|
borderUnderlay
|
||||||
|
accentSubmit={workspaceSession()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function createPromptProjectControls() {
|
|||||||
const target = global.ensureServerCtx(conn)
|
const target = global.ensureServerCtx(conn)
|
||||||
target.projects.open(worktree)
|
target.projects.open(worktree)
|
||||||
target.projects.touch(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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -782,10 +782,7 @@ export function SessionSidePanel(props: {
|
|||||||
when={settings.general.newLayoutDesigns()}
|
when={settings.general.newLayoutDesigns()}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
{props.reviewCount}{" "}
|
{props.reviewCount} {language.plural("session.review.change", props.reviewCount)}
|
||||||
{language.t(
|
|
||||||
props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
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 { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
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 { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
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 { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||||
import { normalize } from "@opencode-ai/session-ui/session-diff"
|
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 { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
import { useLanguage } from "@/context/language"
|
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 { scheduleConnectedMeasure } from "./measure"
|
||||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||||
import { filterVirtualIndexes } from "./virtual-items"
|
import { filterVirtualIndexes } from "./virtual-items"
|
||||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
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"
|
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
const emptyTools: ToolPart[] = []
|
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 language = useLanguage()
|
||||||
const maxFiles = 10
|
const maxFiles = 10
|
||||||
const [state, setState] = createStore({
|
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")}
|
{showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
{props.action}
|
||||||
</div>
|
</div>
|
||||||
<div data-component="session-turn-diffs-content">
|
<div data-component="session-turn-diffs-content">
|
||||||
<Accordion
|
<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 }) {
|
function TimelineDiffView(props: { diff: SummaryDiff }) {
|
||||||
const fileComponent = useFileComponent()
|
const fileComponent = useFileComponent()
|
||||||
const view = normalize(props.diff)
|
const view = normalize(props.diff)
|
||||||
@@ -219,6 +388,10 @@ type MessageTimelineProps = {
|
|||||||
centered: boolean
|
centered: boolean
|
||||||
setContentRef: (el: HTMLDivElement) => void
|
setContentRef: (el: HTMLDivElement) => void
|
||||||
userMessages: UserMessage[]
|
userMessages: UserMessage[]
|
||||||
|
diffs: Accessor<{ additions: number; deletions: number }[] | undefined>
|
||||||
|
onReview: () => void
|
||||||
|
workspaceMoveEligible: boolean
|
||||||
|
onSummaryOpenChange: (open: boolean) => void
|
||||||
anchor: (id: string) => string
|
anchor: (id: string) => string
|
||||||
setRevealMessage?: (fn: (id: string) => void) => void
|
setRevealMessage?: (fn: (id: string) => void) => void
|
||||||
setScrollToEnd?: (fn: () => void) => void
|
setScrollToEnd?: (fn: () => void) => void
|
||||||
@@ -241,6 +414,9 @@ function MessageTimelineView(
|
|||||||
) {
|
) {
|
||||||
let touchGesture: number | undefined
|
let touchGesture: number | undefined
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
|
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
|
||||||
const hasScrollGesture = createMemo(() => props.hasScrollGesture)
|
const hasScrollGesture = createMemo(() => props.hasScrollGesture)
|
||||||
const ownerSessionKey = props.data.sessionKey()
|
const ownerSessionKey = props.data.sessionKey()
|
||||||
@@ -257,19 +433,35 @@ function MessageTimelineView(
|
|||||||
const parentID = props.data.parentID
|
const parentID = props.data.parentID
|
||||||
const parentTitle = props.data.parentTitle
|
const parentTitle = props.data.parentTitle
|
||||||
const childTitle = props.data.childTitle
|
const childTitle = props.data.childTitle
|
||||||
const showHeader = props.data.showHeader
|
|
||||||
const getMsgParts = props.data.parts
|
const getMsgParts = props.data.parts
|
||||||
const getMsgPart = props.data.part
|
const getMsgPart = props.data.part
|
||||||
const projection = props.data.projection
|
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 activeMessageID = projection.activeMessageID
|
||||||
const assistantMessagesByParent = projection.assistantMessagesByParent
|
const assistantMessagesByParent = projection.assistantMessagesByParent
|
||||||
const lastAssistantGroupKey = projection.lastAssistantGroupKey
|
const lastAssistantGroupKey = projection.lastAssistantGroupKey
|
||||||
const messageByID = projection.messageByID
|
const messageByID = projection.messageByID
|
||||||
const sessionMessageByID = projection.sessionMessageByID
|
|
||||||
const messageLastRowIndex = projection.messageLastRowIndex
|
const messageLastRowIndex = projection.messageLastRowIndex
|
||||||
const messageRowIndex = projection.messageRowIndex
|
const messageRowIndex = projection.messageRowIndex
|
||||||
const timelineRowByKey = projection.rowByKey
|
const timelineRowByKey = projection.rowByKey
|
||||||
const timelineRows = projection.rows
|
const timelineRows = projection.rows
|
||||||
|
const sessionMessageByID = projection.sessionMessageByID
|
||||||
const noticeContent = (message: SessionMessageInfo) => {
|
const noticeContent = (message: SessionMessageInfo) => {
|
||||||
if (message.type === "agent-switched")
|
if (message.type === "agent-switched")
|
||||||
return {
|
return {
|
||||||
@@ -788,7 +980,7 @@ function MessageTimelineView(
|
|||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={commentStripRow()}>
|
<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="ms-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||||
<div class="flex w-max min-w-full justify-end gap-2">
|
<div class="flex w-max min-w-full justify-end gap-2">
|
||||||
<Index each={comments()}>
|
<Index each={comments()}>
|
||||||
@@ -839,7 +1031,7 @@ function MessageTimelineView(
|
|||||||
<TimelineRowFrame row={userMessageRow()}>
|
<TimelineRowFrame row={userMessageRow()}>
|
||||||
<Show when={message()}>
|
<Show when={message()}>
|
||||||
{(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">
|
<div data-slot="session-turn-message-content" aria-live="off">
|
||||||
<Message
|
<Message
|
||||||
message={message()}
|
message={message()}
|
||||||
@@ -865,7 +1057,7 @@ function MessageTimelineView(
|
|||||||
<TimelineRowFrame row={noticeRow()}>
|
<TimelineRowFrame row={noticeRow()}>
|
||||||
<Show when={content()}>
|
<Show when={content()}>
|
||||||
{(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>
|
<span class="text-13-medium text-text-strong">{content().label}</span>
|
||||||
<Show when={content().data}>{(data) => <span class="text-text-weak"> · {data()}</span>}</Show>
|
<Show when={content().data}>{(data) => <span class="text-text-weak"> · {data()}</span>}</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -878,7 +1070,7 @@ function MessageTimelineView(
|
|||||||
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={turnDividerRow()}>
|
<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">
|
<div data-slot="session-turn-compaction">
|
||||||
<MessageDivider
|
<MessageDivider
|
||||||
label={language.t(
|
label={language.t(
|
||||||
@@ -894,7 +1086,7 @@ function MessageTimelineView(
|
|||||||
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
|
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={assistantPartRow()}>
|
<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
|
<div
|
||||||
data-slot="session-turn-assistant-content"
|
data-slot="session-turn-assistant-content"
|
||||||
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
|
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
|
||||||
@@ -909,7 +1101,7 @@ function MessageTimelineView(
|
|||||||
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
|
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={thinkingRow()}>
|
<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
|
<TimelineThinkingRow
|
||||||
reasoningHeading={thinkingRow().reasoningHeading}
|
reasoningHeading={thinkingRow().reasoningHeading}
|
||||||
showReasoningSummaries={props.data.showReasoningSummaries()}
|
showReasoningSummaries={props.data.showReasoningSummaries()}
|
||||||
@@ -922,7 +1114,7 @@ function MessageTimelineView(
|
|||||||
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={retryRow()}>
|
<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} />
|
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
||||||
</div>
|
</div>
|
||||||
</TimelineRowFrame>
|
</TimelineRowFrame>
|
||||||
@@ -930,10 +1122,34 @@ function MessageTimelineView(
|
|||||||
}
|
}
|
||||||
case "DiffSummary": {
|
case "DiffSummary": {
|
||||||
const diffSummaryRow = row as Accessor<TimelineRowByTag<"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 (
|
return (
|
||||||
<TimelineRowFrame row={diffSummaryRow()}>
|
<TimelineRowFrame row={diffSummaryRow()}>
|
||||||
<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()}`}>
|
||||||
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
|
<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>
|
</div>
|
||||||
</TimelineRowFrame>
|
</TimelineRowFrame>
|
||||||
)
|
)
|
||||||
@@ -942,7 +1158,7 @@ function MessageTimelineView(
|
|||||||
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
|
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={errorRow()}>
|
<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">
|
<Card variant="error" class="error-card">
|
||||||
{errorRow().text}
|
{errorRow().text}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1024,7 +1240,7 @@ function MessageTimelineView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||||
classList={{
|
classList={{
|
||||||
@@ -1119,6 +1335,30 @@ function MessageTimelineView(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
<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()}>
|
<Show when={parentID()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1205,6 +1445,46 @@ function MessageTimelineView(
|
|||||||
placement="bottom"
|
placement="bottom"
|
||||||
buttonAppearance={props.data.newLayoutDesigns() ? "v2" : "default"}
|
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={!parentID()}>
|
||||||
<Show
|
<Show
|
||||||
when={props.data.newLayoutDesigns()}
|
when={props.data.newLayoutDesigns()}
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||||
import { findLast } from "@opencode-ai/core/util/array"
|
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import type { UserMessage } from "@/types"
|
import type { UserMessage } from "@/types"
|
||||||
import { useLocal } from "@/context/local"
|
|
||||||
import type { SessionController } from "./session-controller"
|
import type { SessionController } from "./session-controller"
|
||||||
|
|
||||||
type SessionCommandSource = {
|
type SessionCommandSource = {
|
||||||
@@ -58,7 +56,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
|
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
|
||||||
const owner = actions.session.ownership.capture()
|
const owner = actions.session.ownership.capture()
|
||||||
@@ -364,6 +361,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fork = () => {
|
const fork = () => {
|
||||||
|
const sessionID = actions.session.identity.params.id
|
||||||
|
if (!sessionID) return
|
||||||
void openDialog(
|
void openDialog(
|
||||||
() => import("@/components/dialog-fork"),
|
() => import("@/components/dialog-fork"),
|
||||||
(x) => dialog.show(() => <x.DialogFork />),
|
(x) => dialog.show(() => <x.DialogFork />),
|
||||||
|
|||||||
@@ -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"
|
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
|
type CurrentEvent = EventSubscribeOutput extends infer Item
|
||||||
? Item extends { type: infer Type extends string; data: infer Data }
|
? Item extends { type: infer Type extends string; data: infer Data }
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -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* () {
|
Effect.gen(function* () {
|
||||||
const requestedDirectory = Option.getOrUndefined(input.directory)
|
const requestedDirectory = Option.getOrUndefined(input.directory)
|
||||||
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||||
const updater = yield* Updater.Service
|
|
||||||
yield* updater.check().pipe(Effect.forkScoped)
|
|
||||||
const preflight = UpdatePreflight.make()
|
const preflight = UpdatePreflight.make()
|
||||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||||
const server = yield* ServerConnection.resolve({
|
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")),
|
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()
|
preflight.loading()
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const npm = yield* Npm.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)
|
) => 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 AnySQLiteEffectDatabase = SQLiteEffectDatabase<any, any, any>
|
||||||
export type AnySQLiteEffectSelectBase = SQLiteEffectSelectBase<any, any, any, any, any, any, any, any, any, any>
|
export type AnySQLiteEffectSelectBase = SQLiteEffectSelectBase<any, any, any, any, any, any, any, any, any, any>
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
/* oxlint-disable */
|
/* oxlint-disable */
|
||||||
import type { TablesRelationalConfig } from "drizzle-orm/_relations"
|
|
||||||
import type { MigrationMeta } from "drizzle-orm/migrator"
|
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 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 */
|
/** @internal */
|
||||||
export type SQLiteMigrationTableRow = { id: number | null; hash: string; created_at: number }
|
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 = {
|
type SQLiteMigrationBackfillEntry = {
|
||||||
name: string
|
name: string
|
||||||
selector:
|
selector:
|
||||||
@@ -115,139 +103,3 @@ export function buildSQLiteMigrationBackfillStatements(
|
|||||||
|
|
||||||
return statements
|
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -195,18 +195,6 @@ export function hash(value: Schema.Json) {
|
|||||||
return Hash.make(createHash("sha256").update(canonical(value)).digest("hex"))
|
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 {
|
export function applyHashDelta(values: Values, delta: Delta): Values {
|
||||||
const result: Record<string, Hash> = { ...values }
|
const result: Record<string, Hash> = { ...values }
|
||||||
for (const [key, value] of Object.entries(delta)) {
|
for (const [key, value] of Object.entries(delta)) {
|
||||||
|
|||||||
@@ -741,21 +741,43 @@ const layer = Layer.effect(
|
|||||||
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||||
const project = yield* projects.resolve(directory)
|
const project = yield* projects.resolve(directory)
|
||||||
yield* persistProject(project)
|
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({
|
const item = SessionInbox.Item.make({
|
||||||
type: "move",
|
type: "move",
|
||||||
payload: {
|
payload,
|
||||||
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
|
|
||||||
projectID: project.id,
|
|
||||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
|
||||||
},
|
|
||||||
delivery: input.delivery ?? "steer",
|
delivery: input.delivery ?? "steer",
|
||||||
})
|
})
|
||||||
const inboxID = SessionMessage.ID.create()
|
const recovered = yield* SessionInbox.serialized(
|
||||||
yield* SessionInbox.admit(db, bus, {
|
input.sessionID,
|
||||||
id: inboxID,
|
Effect.gen(function* () {
|
||||||
sessionID: input.sessionID,
|
const latest = yield* result.get(input.sessionID)
|
||||||
item,
|
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)
|
yield* execution.wake(input.sessionID)
|
||||||
}),
|
}),
|
||||||
compact: Effect.fn("Session.compact")(function* (input) {
|
compact: Effect.fn("Session.compact")(function* (input) {
|
||||||
|
|||||||
@@ -309,6 +309,16 @@ export const list = Effect.fn("SessionInbox.list")(function* (db: DatabaseServic
|
|||||||
return rows.map(fromRow)
|
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* (
|
export const nextQueued = Effect.fn("SessionInbox.nextQueued")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const MAX_MARKDOWN_BYTES = 5 * 1024 * 1024
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Parser } from "htmlparser2"
|
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 omitted = new Set(["script", "style", "noscript", "iframe", "object", "embed", "meta", "link", "template"])
|
||||||
const blocks = new Set([
|
const blocks = new Set([
|
||||||
@@ -47,7 +50,6 @@ type Frame = {
|
|||||||
|
|
||||||
type Chunk = string | { raw: string }
|
type Chunk = string | { raw: string }
|
||||||
|
|
||||||
export const MAX_MARKDOWN_BYTES = 5 * 1024 * 1024
|
|
||||||
const CONTENT_BYTES = MAX_MARKDOWN_BYTES - 64 * 1024
|
const CONTENT_BYTES = MAX_MARKDOWN_BYTES - 64 * 1024
|
||||||
|
|
||||||
export function convertHTMLToMarkdown(html: string) {
|
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()
|
||||||
|
}
|
||||||
@@ -4,9 +4,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
|||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { Duration, Effect, Schema } from "effect"
|
import { Duration, Effect, Schema } from "effect"
|
||||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { Parser } from "htmlparser2"
|
|
||||||
import { Permission } from "../../permission.js"
|
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"
|
import { collectBoundedResponseBody } from "../http-body.js"
|
||||||
|
|
||||||
export const name = "webfetch"
|
export const name = "webfetch"
|
||||||
@@ -103,11 +102,12 @@ const isTextualMime = (mime: string) =>
|
|||||||
mime.endsWith("+xml") ||
|
mime.endsWith("+xml") ||
|
||||||
mime === "application/javascript" ||
|
mime === "application/javascript" ||
|
||||||
mime === "application/x-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 (!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 === "markdown") return convertHTMLToMarkdown(content)
|
||||||
if (format === "text") return extractTextFromHTML(content)
|
return extractTextFromHTML(content)
|
||||||
return content
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Plugin = {
|
export const Plugin = {
|
||||||
@@ -159,7 +159,7 @@ export const Plugin = {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const content = new TextDecoder().decode(body)
|
const content = new TextDecoder().decode(body)
|
||||||
const output = yield* Effect.try({
|
const output = yield* Effect.tryPromise({
|
||||||
try: () => convert(content, contentType, input.format),
|
try: () => convert(content, contentType, input.format),
|
||||||
catch: (error) => error,
|
catch: (error) => error,
|
||||||
})
|
})
|
||||||
@@ -176,24 +176,3 @@ export const Plugin = {
|
|||||||
.pipe(Effect.orDie)
|
.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 }
|
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ const integrations = Layer.mock(Integration.Service, {
|
|||||||
})
|
})
|
||||||
const npm = Layer.mock(Npm.Service, {
|
const npm = Layer.mock(Npm.Service, {
|
||||||
add: () => Effect.die("unused"),
|
add: () => Effect.die("unused"),
|
||||||
install: () => Effect.die("unused"),
|
|
||||||
which: () => Effect.die("unused"),
|
which: () => Effect.die("unused"),
|
||||||
})
|
})
|
||||||
const aisdk = Layer.mock(AISDK.Service, {
|
const aisdk = Layer.mock(AISDK.Service, {
|
||||||
|
|||||||
@@ -125,9 +125,6 @@ describe("Instructions", () => {
|
|||||||
expect(
|
expect(
|
||||||
Instructions.renderUpdate(instructions, { "api/value": "previous" }, { "api/value": Option.some(null) }),
|
Instructions.renderUpdate(instructions, { "api/value": "previous" }, { "api/value": Option.some(null) }),
|
||||||
).toBe("null")
|
).toBe("null")
|
||||||
expect(Instructions.applyDelta({ "api/value": "previous" }, { "api/value": Option.some(null) })).toEqual({
|
|
||||||
"api/value": null,
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ export const readUpdate = (instructions: Instructions.List, previous: State) =>
|
|||||||
hash === "removed" ? Option.none() : Option.some(admission.blobs[hash]),
|
hash === "removed" ? Option.none() : Option.some(admission.blobs[hash]),
|
||||||
]),
|
]),
|
||||||
) as Readonly<Record<string, Option.Option<Schema.Json>>>
|
) 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 {
|
return {
|
||||||
values,
|
values,
|
||||||
text: Instructions.renderUpdate(instructions, previous.values, delta),
|
text: Instructions.renderUpdate(instructions, previous.values, delta),
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const npmLayer = Layer.succeed(
|
|||||||
Npm.Service,
|
Npm.Service,
|
||||||
Npm.Service.of({
|
Npm.Service.of({
|
||||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||||
install: () => Effect.void,
|
|
||||||
which: () => Effect.succeed(undefined),
|
which: () => Effect.succeed(undefined),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
|
|||||||
function npmEntrypoint(entrypoint?: string) {
|
function npmEntrypoint(entrypoint?: string) {
|
||||||
return Npm.Service.of({
|
return Npm.Service.of({
|
||||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||||
install: () => Effect.void,
|
|
||||||
which: () => Effect.succeed(undefined),
|
which: () => Effect.succeed(undefined),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.ur
|
|||||||
const it = testEffect(PluginTestLayer)
|
const it = testEffect(PluginTestLayer)
|
||||||
const npm = Npm.Service.of({
|
const npm = Npm.Service.of({
|
||||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||||
install: () => Effect.void,
|
|
||||||
which: () => Effect.succeed(undefined),
|
which: () => Effect.succeed(undefined),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { mkdir, rm } from "fs/promises"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||||
import { Bus } from "@opencode-ai/core/bus"
|
import { Bus } from "@opencode-ai/core/bus"
|
||||||
@@ -29,7 +30,7 @@ const it = testEffect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
describe("Session.move", () => {
|
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.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
@@ -38,29 +39,26 @@ describe("Session.move", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
const destination = AbsolutePath.make(tmp.path)
|
const destination = AbsolutePath.make(tmp.path)
|
||||||
|
const source = path.join(tmp.path, "source")
|
||||||
|
yield* Effect.promise(() => mkdir(source))
|
||||||
const created = yield* session.create({
|
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 })
|
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(
|
yield* Effect.promise(() => rm(source, { recursive: true }))
|
||||||
AbsolutePath.make(path.join(tmp.path, "deleted")),
|
yield* session.move({ sessionID: created.id, directory: destination })
|
||||||
)
|
|
||||||
expect(yield* session.inbox(created.id)).toMatchObject([
|
expect((yield* session.get(created.id)).location.directory).toBe(destination)
|
||||||
{
|
expect(yield* session.inbox(created.id)).toEqual([])
|
||||||
type: "move",
|
|
||||||
delivery: "steer",
|
|
||||||
payload: {
|
|
||||||
location: { directory: destination },
|
|
||||||
projectID: Project.ID.global,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
yield* session.move({ sessionID: created.id, directory: destination })
|
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({
|
const steered = yield* session.create({
|
||||||
location: Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "other")) }),
|
location: Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "other")) }),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Permission } from "@opencode-ai/core/permission"
|
|||||||
import { Session } from "@opencode-ai/core/session"
|
import { Session } from "@opencode-ai/core/session"
|
||||||
import { Tool } from "@opencode-ai/core/tool"
|
import { Tool } from "@opencode-ai/core/tool"
|
||||||
import { WebFetchTool } from "@opencode-ai/core/tool/plugin/webfetch"
|
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 { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Image } from "@opencode-ai/core/image"
|
import { Image } from "@opencode-ai/core/image"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
@@ -70,52 +71,50 @@ describe("WebFetchTool helpers", () => {
|
|||||||
test("ports HTML text and markdown conversions without active content", () => {
|
test("ports HTML text and markdown conversions without active content", () => {
|
||||||
const html =
|
const html =
|
||||||
"<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong> <product-name>today</product-name></p><style>.bad {}</style>"
|
"<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(extractTextFromHTML(html)).toBe("Helloworld wide today")
|
||||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide** today")
|
expect(convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide** today")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders headings, inline semantics, links, images, breaks, and thematic breaks", () => {
|
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>`
|
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~~`,
|
`## 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", () => {
|
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& stays decoded</code></pre>`
|
const html = `<p>Use <code>say(\`hello\`)</code> now.</p><pre><code class="language-ts">const fence = \`\`\`\n& 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~~~`,
|
`Use \`\`say(\`hello\`)\`\` now.\n\n~~~ts\nconst fence = \`\`\`\n& stays decoded\n~~~`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps nested ordered and unordered lists structurally readable", () => {
|
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>`
|
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(
|
expect(convertHTMLToMarkdown(html)).toBe(`3. alpha\n\n - nested **item**\n\n4. beta first\n\n beta second`)
|
||||||
`3. alpha\n\n - nested **item**\n\n4. beta first\n\n beta second`,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders blockquotes and tables as readable Markdown", () => {
|
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>`
|
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\` |`,
|
`> quoted *text*\n\n> - point\n\n| Name | Value |\n| --- | --- |\n| one | \`1\` |`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("decodes entities and normalizes prose whitespace without joining words", () => {
|
test("decodes entities and normalizes prose whitespace without joining words", () => {
|
||||||
const html = `<p>alpha\n <span>& beta</span> <unknown>café</unknown> gamma 😀</p><p>delta</p>`
|
const html = `<p>alpha\n <span>& beta</span> <unknown>café</unknown> 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", () => {
|
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>`
|
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", () => {
|
test("is deterministic and bounded for malformed maximum-size input", () => {
|
||||||
const html = `<main><p>${"visible & text ".repeat(250_000)}</main></p></unknown>`
|
const html = `<main><p>${"visible & text ".repeat(250_000)}</main></p></unknown>`
|
||||||
const first = WebFetchTool.convertHTMLToMarkdown(html)
|
const first = convertHTMLToMarkdown(html)
|
||||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(first)
|
expect(convertHTMLToMarkdown(html)).toBe(first)
|
||||||
expect(first.startsWith("visible & text visible & text")).toBe(true)
|
expect(first.startsWith("visible & text visible & text")).toBe(true)
|
||||||
expect(first.length).toBeLessThanOrEqual(html.length)
|
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 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 quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
|
||||||
const code = `<pre>${"` x ".repeat(250_000)}</pre>`
|
const code = `<pre>${"` x ".repeat(250_000)}</pre>`
|
||||||
expect(WebFetchTool.convertHTMLToMarkdown(lists).length).toBeLessThan(lists.length * 4)
|
expect(convertHTMLToMarkdown(lists).length).toBeLessThan(lists.length * 4)
|
||||||
expect(WebFetchTool.convertHTMLToMarkdown(quotes).length).toBeLessThan(quotes.length * 4)
|
expect(convertHTMLToMarkdown(quotes).length).toBeLessThan(quotes.length * 4)
|
||||||
expect(() => WebFetchTool.convertHTMLToMarkdown(code)).not.toThrow()
|
expect(() => convertHTMLToMarkdown(code)).not.toThrow()
|
||||||
expect(
|
expect(
|
||||||
WebFetchTool.convertHTMLToMarkdown(
|
convertHTMLToMarkdown("<div>".repeat(20_000) + "safe<script><b>bad</b>&</script><p>tail &</p>"),
|
||||||
"<div>".repeat(20_000) + "safe<script><b>bad</b>&</script><p>tail &</p>",
|
|
||||||
),
|
|
||||||
).toBe("safe tail &")
|
).toBe("safe tail &")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("escapes prose that would otherwise become Markdown structure", () => {
|
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`,
|
`\\# heading\n\n1\\. item\n\n\\---\n\na \\| b`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves code whitespace and quotes every line of multiline blocks", () => {
|
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>`
|
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> | --- | --- |`,
|
`> \`\`\`\n> line \n> \n> \n> next\n> \`\`\`\n\n> | a\\|b | c |\n> | --- | --- |`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps nested blockquotes inside their outer quote", () => {
|
test("keeps nested blockquotes inside their outer quote", () => {
|
||||||
const html = `<blockquote><p>outer</p><blockquote><p>inner</p></blockquote><p>end</p></blockquote>`
|
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", () => {
|
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(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(`a<strong> </strong>b a<em> </em>b`)).toBe(`a b a b`)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("captures formatting elements inside preformatted content as code only", () => {
|
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", () => {
|
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>`
|
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", () => {
|
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>`
|
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 |`,
|
`| Parent | Sibling |\n| --- | --- |\n| Before Key Value A 1 After | Tail |`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves loose text around malformed table rows", () => {
|
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| --- |`,
|
`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", () => {
|
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\\~\\~\\~`,
|
`\\~\\~\\~\n\ncontent\n\n\\~\\~\\~`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -190,10 +187,10 @@ describe("WebFetchTool helpers", () => {
|
|||||||
const small = "<a".repeat(250_000)
|
const small = "<a".repeat(250_000)
|
||||||
const large = "<a".repeat(1_000_000)
|
const large = "<a".repeat(1_000_000)
|
||||||
const start = Bun.nanoseconds()
|
const start = Bun.nanoseconds()
|
||||||
WebFetchTool.convertHTMLToMarkdown(small)
|
convertHTMLToMarkdown(small)
|
||||||
const smallDuration = Bun.nanoseconds() - start
|
const smallDuration = Bun.nanoseconds() - start
|
||||||
const next = Bun.nanoseconds()
|
const next = Bun.nanoseconds()
|
||||||
WebFetchTool.convertHTMLToMarkdown(large)
|
convertHTMLToMarkdown(large)
|
||||||
const largeDuration = Bun.nanoseconds() - next
|
const largeDuration = Bun.nanoseconds() - next
|
||||||
expect(largeDuration).toBeLessThan(smallDuration * 10)
|
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", () => {
|
test("caps escaped prose and backtick-heavy pre output at the webfetch response ceiling", () => {
|
||||||
const prose = `<p>${"*".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</p>`
|
const prose = `<p>${"*".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</p>`
|
||||||
const code = `<pre>${"`".repeat(WebFetchTool.MAX_RESPONSE_BYTES - 11)}</pre>`
|
const code = `<pre>${"`".repeat(WebFetchTool.MAX_RESPONSE_BYTES - 11)}</pre>`
|
||||||
const proseOutput = WebFetchTool.convertHTMLToMarkdown(prose)
|
const proseOutput = convertHTMLToMarkdown(prose)
|
||||||
const codeOutput = WebFetchTool.convertHTMLToMarkdown(code)
|
const codeOutput = convertHTMLToMarkdown(code)
|
||||||
expect(Buffer.byteLength(proseOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
expect(Buffer.byteLength(proseOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||||
expect(Buffer.byteLength(codeOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
expect(Buffer.byteLength(codeOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||||
expect(codeOutput.startsWith("~~~\n")).toBe(true)
|
expect(codeOutput.startsWith("~~~\n")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not confuse source NUL text with buffered code", () => {
|
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\`\`\``,
|
`before \u00000\u0000 after\n\n\`\`\`\ncode\n\`\`\``,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves multiline inline code verbatim", () => {
|
test("preserves multiline inline code verbatim", () => {
|
||||||
expect(WebFetchTool.convertHTMLToMarkdown(`<p><code>first\n\n\nsecond </code></p>`)).toBe(
|
expect(convertHTMLToMarkdown(`<p><code>first\n\n\nsecond </code></p>`)).toBe("` first\n\n\nsecond `")
|
||||||
"` first\n\n\nsecond `",
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("prefixes inline code at the start of a blockquote line", () => {
|
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", () => {
|
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>`
|
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.`,
|
`**\` 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(
|
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>`,
|
`<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.`)
|
).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\``,
|
`\`a\`[\`b\`](\/x)[\`c\`](\/y)\`de\``,
|
||||||
)
|
)
|
||||||
expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x">b</code>c`)).toBe(`\`a\`[\`b\`](\/x)c`)
|
expect(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(
|
expect(convertHTMLToMarkdown(`<code>a<a href="/x"><div>b</div>c</a>d</code>`)).toBe(`\`a\`[](\/x)\n\n\`bcd\``)
|
||||||
`\`a\`[](\/x)\n\n\`bcd\``,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("indents nested list continuations and preserves ordered numbering", () => {
|
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>`
|
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`,
|
`4. first\n\n continued\n\n - nested\n\n continued nested\n\n5. next`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders block content outside link syntax", () => {
|
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)`,
|
`[before](/docs)\n\nblock\n\n[after](/docs)`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("recovers nested anchors without unmatched Markdown syntax", () => {
|
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", () => {
|
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", () => {
|
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>`
|
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", () => {
|
test("keeps each near-boundary inline construct closed and UTF-8-safe", () => {
|
||||||
@@ -279,7 +272,7 @@ describe("WebFetchTool helpers", () => {
|
|||||||
[`<code>${payload}</code>`, /^`[\s\S]*`$/],
|
[`<code>${payload}</code>`, /^`[\s\S]*`$/],
|
||||||
] as const
|
] as const
|
||||||
for (const [html, pattern] of cases) {
|
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(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||||
expect(output).not.toContain("�")
|
expect(output).not.toContain("�")
|
||||||
expect(output).toMatch(pattern)
|
expect(output).toMatch(pattern)
|
||||||
@@ -288,11 +281,9 @@ describe("WebFetchTool helpers", () => {
|
|||||||
|
|
||||||
test("keeps near-boundary block constructs syntactically complete", () => {
|
test("keeps near-boundary block constructs syntactically complete", () => {
|
||||||
const payload = "x".repeat(WebFetchTool.MAX_RESPONSE_BYTES)
|
const payload = "x".repeat(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||||
const table = WebFetchTool.convertHTMLToMarkdown(
|
const table = convertHTMLToMarkdown(`<table><tr><th>Name</th></tr><tr><td>${payload}</td></tr></table>`)
|
||||||
`<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>`)
|
||||||
const list = WebFetchTool.convertHTMLToMarkdown(`<ul><li>${payload}</li></ul><ul><li>nested</li></ul>`)
|
|
||||||
const code = WebFetchTool.convertHTMLToMarkdown(`<pre>${payload}</pre>`)
|
|
||||||
for (const output of [table, list, code]) {
|
for (const output of [table, list, code]) {
|
||||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||||
expect(output).not.toContain("�")
|
expect(output).not.toContain("�")
|
||||||
@@ -305,7 +296,7 @@ describe("WebFetchTool helpers", () => {
|
|||||||
|
|
||||||
test("keeps quoted code within budget with a safe closed fence", () => {
|
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 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)
|
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||||
const lines = output.split("\n")
|
const lines = output.split("\n")
|
||||||
expect(lines[0]).toMatch(/^> (`{33}|~{33})$/)
|
expect(lines[0]).toMatch(/^> (`{33}|~{33})$/)
|
||||||
@@ -314,14 +305,14 @@ describe("WebFetchTool helpers", () => {
|
|||||||
|
|
||||||
test("separates reconstructed tables from adjacent inline and quoted content", () => {
|
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>`
|
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| --- |`,
|
`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", () => {
|
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 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(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||||
expect((output.match(/(`{3}|~{3})/g) ?? []).length).toBe(2)
|
expect((output.match(/(`{3}|~{3})/g) ?? []).length).toBe(2)
|
||||||
expect(output.includes("\uFFFD")).toBe(false)
|
expect(output.includes("\uFFFD")).toBe(false)
|
||||||
@@ -330,54 +321,52 @@ describe("WebFetchTool helpers", () => {
|
|||||||
|
|
||||||
test("keeps active content suppressed when depth fallback begins", () => {
|
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>`
|
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", () => {
|
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>`
|
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", () => {
|
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>`
|
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)`,
|
`[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", () => {
|
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`,
|
`100. outer\n\n - inner`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders captions and definition lists with readable boundaries", () => {
|
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>`
|
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`,
|
`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", () => {
|
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>`
|
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", () => {
|
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>`
|
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", () => {
|
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>`
|
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(
|
expect(convertHTMLToMarkdown(html)).toBe(`\`\`\`\nfirst\nsecond\n\`\`\`\n\n[link](/x "line one line two")`)
|
||||||
`\`\`\`\nfirst\nsecond\n\`\`\`\n\n[link](/x "line one line two")`,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders closed and open details according to visibility", () => {
|
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>`
|
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 } },
|
{ 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(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",
|
status: "completed",
|
||||||
content: [{ type: "text", text: "Helloworld" }],
|
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>" }],
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import { MetaProvider } from "@solidjs/meta"
|
|||||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
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 uiEn } from "@opencode-ai/ui/i18n/en"
|
||||||
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
|
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
|
||||||
import { createEffect, createMemo, Suspense, type ParentProps } from "solid-js"
|
import { createEffect, createMemo, Suspense, type ParentProps } from "solid-js"
|
||||||
@@ -58,20 +65,30 @@ function detectLocale() {
|
|||||||
function UiI18nBridge(props: ParentProps) {
|
function UiI18nBridge(props: ParentProps) {
|
||||||
const locale = createMemo(() => detectLocale())
|
const locale = createMemo(() => detectLocale())
|
||||||
const zh = uiZh as Partial<Record<string, string>>
|
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 value = locale() === "zh" ? (zh[key] ?? uiEn[key]) : uiEn[key]
|
||||||
const text = value ?? String(key)
|
const text = value ?? String(key)
|
||||||
return resolveTemplate(text, params)
|
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) =>
|
const plural = (key: UiI18nPluralKey, count: number, params?: UiI18nParams) =>
|
||||||
t(pluralKey(key, pluralCategory(locale(), count)), { ...params, count })
|
pluralForm(key, pluralCategory(locale(), count), { ...params, count })
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document !== "object") return
|
if (typeof document !== "object") return
|
||||||
document.documentElement.lang = locale()
|
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() {
|
export default function App() {
|
||||||
|
|||||||
@@ -4,9 +4,4 @@ import { Event } from "./event.js"
|
|||||||
import { Worktree } from "./worktree.js"
|
import { Worktree } from "./worktree.js"
|
||||||
import { SessionEvent } from "./session-event.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])
|
export const Durable = Event.durableMap([...SessionEvent.DurableDefinitions, Worktree.Event.Resolved])
|
||||||
|
|||||||
@@ -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
|
// Emitted whenever a server's connection status settles (connected, failed, needs_auth, closed) so
|
||||||
// observers can refresh status without polling.
|
// observers can refresh status without polling.
|
||||||
export const StatusChanged = Event.ephemeral({
|
export const StatusChanged = Event.ephemeral({
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
## Localization
|
## 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.
|
- 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.
|
- 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.
|
- 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`.
|
- 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);
|
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]) {
|
body:not([data-new-layout]) {
|
||||||
[data-component="user-message"] {
|
[data-component="user-message"] {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
|
|||||||
@@ -550,7 +550,7 @@ export function getToolInfo(
|
|||||||
icon: "code-lines",
|
icon: "code-lines",
|
||||||
title: i18n.t("ui.tool.patch"),
|
title: i18n.t("ui.tool.patch"),
|
||||||
subtitle: input.files?.length
|
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,
|
: undefined,
|
||||||
}
|
}
|
||||||
case "todowrite":
|
case "todowrite":
|
||||||
@@ -2344,7 +2344,7 @@ ToolRegistry.register({
|
|||||||
const subtitle = createMemo(() => {
|
const subtitle = createMemo(() => {
|
||||||
const count = files().length
|
const count = files().length
|
||||||
if (count === 0) return ""
|
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 (
|
return (
|
||||||
@@ -2585,7 +2585,7 @@ ToolRegistry.register({
|
|||||||
const count = questions().length
|
const count = questions().length
|
||||||
if (count === 0) return ""
|
if (count === 0) return ""
|
||||||
if (completed()) return i18n.t("ui.question.subtitle.answered", { count })
|
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 (
|
return (
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ function common(one: string, other: string) {
|
|||||||
export function AnimatedCountLabel(props: { count: number; plural: UiI18nPluralKey; class?: string }) {
|
export function AnimatedCountLabel(props: { count: number; plural: UiI18nPluralKey; class?: string }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const category = createMemo(() => pluralCategory(i18n.locale(), Math.round(props.count)))
|
const category = createMemo(() => pluralCategory(i18n.locale(), Math.round(props.count)))
|
||||||
const one = createMemo(() => split(i18n.t(pluralKey(props.plural, "one"))))
|
const form = (category: ReturnType<typeof pluralCategory>) =>
|
||||||
const other = createMemo(() => split(i18n.t(pluralKey(props.plural, "other"))))
|
i18n.pluralForm?.(props.plural, category) ?? (i18n.t as (key: string) => string)(pluralKey(props.plural, category))
|
||||||
const active = createMemo(() => split(i18n.t(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 suffix = createMemo(() => common(one().after, other().after))
|
||||||
const splitSuffix = createMemo(
|
const splitSuffix = createMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type PromptInputV2Mode = "normal" | "shell"
|
|||||||
|
|
||||||
export type PromptInputV2Props = {
|
export type PromptInputV2Props = {
|
||||||
controller: PromptInputV2Interaction
|
controller: PromptInputV2Interaction
|
||||||
|
accentSubmit?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
borderUnderlay?: boolean
|
borderUnderlay?: boolean
|
||||||
@@ -263,6 +264,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
|||||||
mode={state.mode}
|
mode={state.mode}
|
||||||
stopping={view.submit.stopping()}
|
stopping={view.submit.stopping()}
|
||||||
disabled={!props.controller.canSubmit()}
|
disabled={!props.controller.canSubmit()}
|
||||||
|
accent={props.accentSubmit}
|
||||||
sendLabel={i18n.t("ui.promptInput.send")}
|
sendLabel={i18n.t("ui.promptInput.send")}
|
||||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||||
onSubmit={props.controller.submit}
|
onSubmit={props.controller.submit}
|
||||||
@@ -678,6 +680,7 @@ export function PromptInputV2SubmitButton(props: {
|
|||||||
mode: PromptInputV2Mode
|
mode: PromptInputV2Mode
|
||||||
stopping: boolean
|
stopping: boolean
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
|
accent?: boolean
|
||||||
sendLabel: string
|
sendLabel: string
|
||||||
stopLabel: string
|
stopLabel: string
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
@@ -696,10 +699,16 @@ export function PromptInputV2SubmitButton(props: {
|
|||||||
tabIndex={props.mode === "normal" ? undefined : -1}
|
tabIndex={props.mode === "normal" ? undefined : -1}
|
||||||
icon={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
icon={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||||
variant="primary"
|
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={{
|
style={{
|
||||||
"background-image":
|
"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}
|
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
|||||||
@@ -665,14 +665,17 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
slash: { name: "new", aliases: ["clear"] },
|
slash: { name: "new", aliases: ["clear"] },
|
||||||
run: () => {
|
run: () => {
|
||||||
|
const current =
|
||||||
|
route.data.type === "session"
|
||||||
|
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
|
||||||
|
: undefined
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "home",
|
type: "home",
|
||||||
location: newSessionLocation(
|
location: newSessionLocation(
|
||||||
config.data.session.new_location,
|
config.data.session.new_location,
|
||||||
paths.cwd,
|
paths.cwd,
|
||||||
route.data.type === "session"
|
current,
|
||||||
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
|
location.error?.location,
|
||||||
: undefined,
|
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
onCleanup,
|
onCleanup,
|
||||||
untrack,
|
untrack,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { Portal, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { useConfig } from "../config"
|
import { useConfig } from "../config"
|
||||||
import { useSessionTabs } from "../context/session-tabs"
|
import { useSessionTabs } from "../context/session-tabs"
|
||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
@@ -37,8 +37,6 @@ import { projectName } from "../util/project"
|
|||||||
import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../util/marquee"
|
import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../util/marquee"
|
||||||
import { useDialog } from "../ui/dialog"
|
import { useDialog } from "../ui/dialog"
|
||||||
import { DialogSessionRename } from "./dialog-session-rename"
|
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.
|
// A long title fades out over its last cells instead of cutting hard.
|
||||||
const FADE_WIDTH = 4
|
const FADE_WIDTH = 4
|
||||||
@@ -189,7 +187,6 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
|
|||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const theme = useTheme("elevated")
|
const theme = useTheme("elevated")
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const keymap = Keymap.use()
|
|
||||||
const actions = createMemo(() => {
|
const actions = createMemo(() => {
|
||||||
const sessionID = props.state.sessionID
|
const sessionID = props.state.sessionID
|
||||||
return [
|
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 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 left = () => Math.max(0, Math.min(props.state.x, dimensions().width - CONTEXT_MENU_WIDTH))
|
||||||
const run = (index: number) => {
|
const run = (index: number) => {
|
||||||
|
const action = actions()[index]
|
||||||
props.onClose()
|
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 (
|
return (
|
||||||
<box
|
<Portal>
|
||||||
position="absolute"
|
<box
|
||||||
left={left()}
|
position="absolute"
|
||||||
top={top()}
|
left={0}
|
||||||
height={actions().length}
|
top={0}
|
||||||
width={CONTEXT_MENU_WIDTH}
|
width={dimensions().width}
|
||||||
zIndex={2500}
|
height={dimensions().height}
|
||||||
flexDirection="column"
|
zIndex={2500}
|
||||||
backgroundColor={theme.background.default}
|
onMouseDown={(event) => {
|
||||||
onMouseDown={(event) => {
|
props.onClose()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<For each={actions()}>
|
<box
|
||||||
{(action, index) => (
|
position="absolute"
|
||||||
<box
|
left={left()}
|
||||||
width="100%"
|
top={top()}
|
||||||
paddingLeft={1}
|
height={actions().length}
|
||||||
paddingRight={1}
|
width={CONTEXT_MENU_WIDTH}
|
||||||
backgroundColor={selected() === index() ? theme.background.action.primary.hovered : undefined}
|
flexDirection="column"
|
||||||
onMouseOver={() => setSelected(index())}
|
backgroundColor={theme.background.default}
|
||||||
onMouseUp={(event) => {
|
onMouseDown={(event) => {
|
||||||
event.preventDefault()
|
if (event.button === RIGHT_MOUSE_BUTTON) props.onClose()
|
||||||
event.stopPropagation()
|
event.preventDefault()
|
||||||
run(index())
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<text fg={theme.text.default} selectable={false}>
|
<For each={actions()}>
|
||||||
{action.title}
|
{(action, index) => (
|
||||||
</text>
|
<box
|
||||||
</box>
|
width="100%"
|
||||||
)}
|
paddingLeft={1}
|
||||||
</For>
|
paddingRight={1}
|
||||||
</box>
|
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)
|
setDragging(undefined)
|
||||||
if (!rail) return
|
if (!rail) return
|
||||||
setContextMenu({
|
setContextMenu({
|
||||||
x: event.x - rail.screenX,
|
x: event.x,
|
||||||
y: event.y - rail.screenY,
|
y: event.y,
|
||||||
sessionID: tab.sessionID,
|
sessionID: tab.sessionID,
|
||||||
title: tab.title,
|
title: tab.title,
|
||||||
})
|
})
|
||||||
@@ -712,7 +705,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||||||
onMouseDown={(event: MouseEvent) => {
|
onMouseDown={(event: MouseEvent) => {
|
||||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||||
if (!rail) 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.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
@@ -1037,8 +1030,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||||||
if (event.button === RIGHT_MOUSE_BUTTON) {
|
if (event.button === RIGHT_MOUSE_BUTTON) {
|
||||||
setDragging(undefined)
|
setDragging(undefined)
|
||||||
setContextMenu({
|
setContextMenu({
|
||||||
x: event.x - (strip?.screenX ?? 0),
|
x: event.x,
|
||||||
y: event.y - (strip?.screenY ?? 0),
|
y: event.y,
|
||||||
sessionID: tab === NEW_SESSION_TAB ? undefined : tab.sessionID,
|
sessionID: tab === NEW_SESSION_TAB ? undefined : tab.sessionID,
|
||||||
title: tab === NEW_SESSION_TAB ? undefined : tab.title,
|
title: tab === NEW_SESSION_TAB ? undefined : tab.title,
|
||||||
})
|
})
|
||||||
@@ -1134,7 +1127,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||||||
onMouseOut={() => setAddHovered(false)}
|
onMouseOut={() => setAddHovered(false)}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
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.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ export function newSessionLocation(
|
|||||||
mode: "launch" | "inherit",
|
mode: "launch" | "inherit",
|
||||||
launchDirectory: string,
|
launchDirectory: string,
|
||||||
current?: LocationRef,
|
current?: LocationRef,
|
||||||
|
unavailable?: LocationRef,
|
||||||
): 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 }
|
return { directory: launchDirectory }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useConfig } from "../config"
|
|||||||
import { useLocation } from "./location"
|
import { useLocation } from "./location"
|
||||||
import { useStorage } from "./storage"
|
import { useStorage } from "./storage"
|
||||||
import { useTuiPaths } from "./runtime"
|
import { useTuiPaths } from "./runtime"
|
||||||
|
import { newSessionLocation } from "../config/new-session-location"
|
||||||
import {
|
import {
|
||||||
closeSessionTab,
|
closeSessionTab,
|
||||||
cycleSessionTab,
|
cycleSessionTab,
|
||||||
@@ -289,9 +290,15 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||||||
add() {
|
add() {
|
||||||
if (!enabled()) return
|
if (!enabled()) return
|
||||||
const sessionID = current()
|
const sessionID = current()
|
||||||
|
const currentLocation = (sessionID ? data.session.get(sessionID)?.location : undefined) ?? location.ref
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "home",
|
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) {
|
close(sessionID?: string) {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { createContext, createSignal, onCleanup, useContext, type ParentProps, Show } from "solid-js"
|
import { createContext, createSignal, onCleanup, useContext, type ParentProps, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { SplitBorder } from "./border"
|
import { SplitBorder } from "./border"
|
||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { tint } from "../theme/color"
|
|
||||||
export type ToastOptions = {
|
export type ToastOptions = {
|
||||||
title?: string
|
title?: string
|
||||||
message: string
|
message: string
|
||||||
@@ -25,11 +24,23 @@ function ToastSurface(props: {
|
|||||||
}) {
|
}) {
|
||||||
const theme = useTheme("overlay")
|
const theme = useTheme("overlay")
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
|
const renderer = useRenderer()
|
||||||
const [hovered, setHovered] = createSignal(false)
|
const [hovered, setHovered] = createSignal(false)
|
||||||
const hover = (value: boolean) => {
|
const hover = (value: boolean) => {
|
||||||
setHovered(value)
|
setHovered(value)
|
||||||
props.onHover?.(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 (
|
return (
|
||||||
<box
|
<box
|
||||||
@@ -44,7 +55,10 @@ function ToastSurface(props: {
|
|||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
onMouseOver={() => hover(true)}
|
onMouseOver={() => hover(true)}
|
||||||
onMouseOut={() => hover(false)}
|
onMouseOut={() => hover(false)}
|
||||||
onMouseUp={props.onActivate}
|
onMouseUp={() => {
|
||||||
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
|
props.onActivate()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<box
|
<box
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -52,9 +66,7 @@ function ToastSurface(props: {
|
|||||||
paddingRight={2}
|
paddingRight={2}
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
backgroundColor={
|
backgroundColor={theme.background.default}
|
||||||
hovered() ? tint(theme.background.default, theme.text.default, 0.04) : theme.background.default
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={props.toast.title}
|
when={props.toast.title}
|
||||||
@@ -63,18 +75,7 @@ function ToastSurface(props: {
|
|||||||
<text fg={theme.text.default} wrapMode="word" flexGrow={1}>
|
<text fg={theme.text.default} wrapMode="word" flexGrow={1}>
|
||||||
{props.toast.message}
|
{props.toast.message}
|
||||||
</text>
|
</text>
|
||||||
<Show when={props.toast.action || hovered()}>
|
{affordance()}
|
||||||
<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>
|
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -83,18 +84,7 @@ function ToastSurface(props: {
|
|||||||
{props.toast.title}
|
{props.toast.title}
|
||||||
</text>
|
</text>
|
||||||
<box flexGrow={1} />
|
<box flexGrow={1} />
|
||||||
<Show when={props.toast.action || hovered()}>
|
{affordance()}
|
||||||
<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>
|
|
||||||
</box>
|
</box>
|
||||||
<text fg={theme.text.default} wrapMode="word" width="100%">
|
<text fg={theme.text.default} wrapMode="word" width="100%">
|
||||||
{props.toast.message}
|
{props.toast.message}
|
||||||
@@ -153,11 +143,15 @@ function init() {
|
|||||||
|
|
||||||
const dismiss = () => {
|
const dismiss = () => {
|
||||||
clear()
|
clear()
|
||||||
paused = false
|
|
||||||
const next = store.queue[0]
|
const next = store.queue[0]
|
||||||
setStore("queue", (queue) => queue.slice(1))
|
setStore("queue", (queue) => queue.slice(1))
|
||||||
setStore("currentToast", next ?? null)
|
setStore("currentToast", next ?? null)
|
||||||
if (next) start(next.duration)
|
if (!next) {
|
||||||
|
paused = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remaining = next.duration
|
||||||
|
if (!paused) start(next.duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toast = {
|
const toast = {
|
||||||
|
|||||||
@@ -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
|
let toast: ToastContext | undefined
|
||||||
const Capture = captureToast((value) => (toast = value))
|
const Capture = captureToast((value) => (toast = value))
|
||||||
const app = await testRender(() => (
|
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) },
|
action: { label: "Open plugins", run: () => (activated = true) },
|
||||||
})
|
})
|
||||||
toast!.pause()
|
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!.currentToast?.message).toBe("Plugin failed")
|
||||||
expect(toast!.pending).toBe(1)
|
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(activated).toBe(true)
|
||||||
expect(toast!.currentToast?.message).toBe("Copied")
|
expect(toast!.currentToast?.message).toBe("Copied")
|
||||||
expect(toast!.pending).toBe(0)
|
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 {
|
} finally {
|
||||||
app.renderer.destroy()
|
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
|
let toast: ToastContext | undefined
|
||||||
const Capture = captureToast((value) => (toast = value))
|
const Capture = captureToast((value) => (toast = value))
|
||||||
const app = await testRender(() => (
|
const app = await testRender(() => (
|
||||||
@@ -56,7 +63,7 @@ test("clicking a toast without an action dismisses it", async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await app.waitFor(() => toast !== undefined)
|
await app.waitFor(() => toast !== undefined)
|
||||||
toast!.show({ message: "Copied", variant: "success" })
|
toast!.show({ message: "Copied", variant: "success", duration: 5 })
|
||||||
toast!.activate()
|
toast!.activate()
|
||||||
expect(toast!.currentToast).toBeNull()
|
expect(toast!.currentToast).toBeNull()
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ async function renderSessionTabs(
|
|||||||
persisted?: string[]
|
persisted?: string[]
|
||||||
sessionGate?: Promise<void>
|
sessionGate?: Promise<void>
|
||||||
sessionDirectories?: Record<string, string>
|
sessionDirectories?: Record<string, string>
|
||||||
|
newLocation?: "launch" | "inherit"
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const temporary = options?.state ? undefined : await tmpdir()
|
const temporary = options?.state ? undefined : await tmpdir()
|
||||||
@@ -106,7 +107,12 @@ async function renderSessionTabs(
|
|||||||
<TestTuiContexts paths={{ state }}>
|
<TestTuiContexts paths={{ state }}>
|
||||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||||
<StorageProvider>
|
<StorageProvider>
|
||||||
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
|
<ConfigProvider
|
||||||
|
config={createTuiResolvedConfig({
|
||||||
|
tabs: { enabled: true },
|
||||||
|
session: { new_location: options?.newLocation ?? "launch" },
|
||||||
|
})}
|
||||||
|
>
|
||||||
<RouteProvider
|
<RouteProvider
|
||||||
initialRoute={options?.home ? { type: "home" } : { type: "session", sessionID: initialSessionID }}
|
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 () => {
|
test("add opens the new session tab in the launch directory by default", async () => {
|
||||||
const setup = await renderSessionTabs("first")
|
const setup = await renderSessionTabs("first", { sessionDirectories: { first: `${directory}/worktree` } })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await wait(() => setup.tabs.current() === "first" && setup.data.session.get("first") !== undefined)
|
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()
|
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", () => {
|
test("falls back to the launch directory without an active session", () => {
|
||||||
expect(newSessionLocation("inherit", "/launch")).toEqual({ directory: "/launch" })
|
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" })
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
## Localization
|
## 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.
|
- 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.
|
- 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.
|
- 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`.
|
- 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`.
|
||||||
|
|||||||
@@ -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"/>`,
|
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"/>`,
|
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"/>`,
|
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"/>`,
|
"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"/>`,
|
"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"/>`,
|
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
import { createContext, useContext, type Accessor, type ParentProps } from "solid-js"
|
import { createContext, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||||
import { I18nProvider } from "@kobalte/core/i18n"
|
import { I18nProvider } from "@kobalte/core/i18n"
|
||||||
import { dict as en } from "../i18n/en"
|
import { dict as en } from "../i18n/en"
|
||||||
|
import type { Key, LocaleKey, PluralCategory, PluralKey, PluralLookupKey } from "../i18n/en"
|
||||||
|
|
||||||
export type UiI18nKey = keyof typeof en
|
export type UiI18nKey = Key
|
||||||
|
export type UiI18nPluralKey = PluralKey
|
||||||
export const UI_PLURAL_KEYS = [
|
export type UiPluralCategory = PluralCategory
|
||||||
"ui.sessionTurn.diffs.changed",
|
export type UiI18nPluralLookupKey = PluralLookupKey
|
||||||
"ui.messagePart.context.read",
|
export type UiI18nLocaleKey = LocaleKey
|
||||||
"ui.messagePart.context.search",
|
type UiTranslationKey<Value extends string> = Value extends UiI18nPluralLookupKey ? never : Value
|
||||||
"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 UiI18nParams = Record<string, string | number | boolean>
|
export type UiI18nParams = Record<string, string | number | boolean>
|
||||||
|
export type UiTranslate = <Value extends string>(key: UiTranslationKey<Value>, params?: UiI18nParams) => string
|
||||||
|
|
||||||
export type UiI18n = {
|
export type UiI18n = {
|
||||||
locale: Accessor<string>
|
locale: Accessor<string>
|
||||||
layoutLocale?: Accessor<string>
|
layoutLocale?: Accessor<string>
|
||||||
t: (key: UiI18nKey, params?: UiI18nParams) => string
|
t: UiTranslate
|
||||||
plural: (key: UiI18nPluralKey, count: number, params?: UiI18nParams) => string
|
plural: (key: UiI18nPluralKey, count: number, params?: UiI18nParams) => string
|
||||||
|
pluralForm?: (key: UiI18nPluralKey, category: UiPluralCategory, params?: UiI18nParams) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules = new Map<string, Intl.PluralRules>()
|
const rules = new Map<string, Intl.PluralRules>()
|
||||||
@@ -50,11 +48,16 @@ function resolveTemplate(text: string, params?: UiI18nParams) {
|
|||||||
const fallback: UiI18n = {
|
const fallback: UiI18n = {
|
||||||
locale: () => "en",
|
locale: () => "en",
|
||||||
t: (key, params) => {
|
t: (key, params) => {
|
||||||
const value = en[key] ?? String(key)
|
const value = en[key as UiI18nKey] ?? String(key)
|
||||||
return resolveTemplate(value, params)
|
return resolveTemplate(value, params)
|
||||||
},
|
},
|
||||||
plural: (key, count, 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)
|
const Context = createContext<UiI18n>(fallback)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const dict: Record<string, string> = {
|
const source = {
|
||||||
"ui.sessionReview.title": "Session changes",
|
"ui.sessionReview.title": "Session changes",
|
||||||
"ui.sessionReview.title.git": "Git changes",
|
"ui.sessionReview.title.git": "Git changes",
|
||||||
"ui.sessionReview.title.branch": "Branch 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.multiHint": "Select all answers that apply",
|
||||||
"ui.question.singleHint": "Select one answer",
|
"ui.question.singleHint": "Select one answer",
|
||||||
"ui.question.custom.placeholder": "Type your 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"] {
|
[data-slot="dialog-description"] {
|
||||||
flex: none;
|
flex: none;
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
font-weight: 440;
|
font-weight: 440;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { onMount, type ComponentProps, splitProps } from "solid-js"
|
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 = {
|
const icons = {
|
||||||
edit: {
|
edit: {
|
||||||
viewBox: "0 0 16 16",
|
viewBox: "0 0 16 16",
|
||||||
@@ -17,6 +18,10 @@ const icons = {
|
|||||||
viewBox: "0 0 16 16",
|
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"/>`,
|
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": {
|
"grid-plus": {
|
||||||
viewBox: "0 0 16 16",
|
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"/>`,
|
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",
|
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"/>`,
|
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": {
|
"outline-sliders": {
|
||||||
viewBox: "0 0 16 16",
|
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"/>`,
|
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"/>`,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function TooltipV2(props: TooltipV2Props) {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const close = () => setState("open", false)
|
const close = () => setState("open", false)
|
||||||
|
const controlled = () => local.forceOpen !== undefined
|
||||||
|
|
||||||
const inside = () => {
|
const inside = () => {
|
||||||
const active = document.activeElement
|
const active = document.activeElement
|
||||||
@@ -93,9 +94,9 @@ export function TooltipV2(props: TooltipV2Props) {
|
|||||||
{...others}
|
{...others}
|
||||||
closeDelay={0}
|
closeDelay={0}
|
||||||
ignoreSafeArea={local.ignoreSafeArea ?? true}
|
ignoreSafeArea={local.ignoreSafeArea ?? true}
|
||||||
open={local.forceOpen || state.open}
|
open={controlled() ? local.forceOpen : state.open}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (local.forceOpen) return
|
if (controlled()) return
|
||||||
if (state.block && open) return
|
if (state.block && open) return
|
||||||
if (justClickedTrigger) {
|
if (justClickedTrigger) {
|
||||||
justClickedTrigger = false
|
justClickedTrigger = false
|
||||||
|
|||||||
@@ -52,8 +52,7 @@
|
|||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.5",
|
||||||
"npm-package-arg": "13.0.2",
|
"npm-package-arg": "13.0.2",
|
||||||
"resolve.exports": "catalog:",
|
"resolve.exports": "catalog:"
|
||||||
"xdg-basedir": "5.1.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
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. */
|
/** The XDG base directories that root opencode's global paths. */
|
||||||
export function roots(app: string) {
|
export function roots(app: string) {
|
||||||
return {
|
return {
|
||||||
data: path.join(xdgData!, app),
|
data: path.join(data!, app),
|
||||||
cache: path.join(xdgCache!, app),
|
cache: path.join(cache!, app),
|
||||||
config: path.join(xdgConfig!, app),
|
config: path.join(config!, app),
|
||||||
state: path.join(xdgState!, app),
|
state: path.join(state!, app),
|
||||||
tmp: path.join(os.tmpdir(), app),
|
tmp: path.join(os.tmpdir(), app),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
export * as NpmConfig from "./npm-config.js"
|
export * as NpmConfig from "./npm-config.js"
|
||||||
|
|
||||||
import { fileURLToPath } from "url"
|
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"
|
import { Effect } from "effect"
|
||||||
|
|
||||||
export const load = (dir: string) =>
|
export const load = (dir: string) =>
|
||||||
Effect.tryPromise({
|
Effect.tryPromise({
|
||||||
try: async () => {
|
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({
|
const config = new Config({
|
||||||
// Resolved per call: on workerd import.meta.url is undefined and building
|
// 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.
|
// this URL at module scope fails startup validation; npm config never runs there.
|
||||||
|
|||||||
@@ -30,15 +30,6 @@ export interface Interface {
|
|||||||
pkg: string,
|
pkg: string,
|
||||||
options?: { readonly subpaths?: readonly string[] },
|
options?: { readonly subpaths?: readonly string[] },
|
||||||
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
) => 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>
|
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)
|
return resolveEntryPoint(first.name, first.path, options?.subpaths)
|
||||||
}, Effect.scoped)
|
}, 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 which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
|
||||||
const dir = directory(pkg)
|
const dir = directory(pkg)
|
||||||
const binDir = path.join(dir, "node_modules", ".bin")
|
const binDir = path.join(dir, "node_modules", ".bin")
|
||||||
@@ -249,7 +187,6 @@ const layer = Layer.effect(
|
|||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
add,
|
add,
|
||||||
install,
|
|
||||||
which,
|
which,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -263,10 +200,6 @@ export const node = makeGlobalNode({
|
|||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, LayerNode.compile(node))
|
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"]>) {
|
export async function add(...args: Parameters<Interface["add"]>) {
|
||||||
return runPromise((svc) => svc.add(...args))
|
return runPromise((svc) => svc.add(...args))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -24,7 +24,6 @@
|
|||||||
"@cloudflare/workers-types": "^4.20250808.0",
|
"@cloudflare/workers-types": "^4.20250808.0",
|
||||||
"vitest": "3.2.7",
|
"vitest": "3.2.7",
|
||||||
"wrangler": "4.28.0",
|
"wrangler": "4.28.0",
|
||||||
"xdg-basedir": "5.1.0",
|
|
||||||
"unenv": "2.0.0-rc.24",
|
"unenv": "2.0.0-rc.24",
|
||||||
"@effect/platform-node": "catalog:"
|
"@effect/platform-node": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,10 +51,8 @@ export default defineWorkersConfig({
|
|||||||
// mime-types requires mime-db's JSON database at require time; keep the
|
// mime-types requires mime-db's JSON database at require time; keep the
|
||||||
// lookup surface but back it with a static shim.
|
// lookup surface but back it with a static shim.
|
||||||
{ find: /^mime-types$/, replacement: new URL("./test/shims/mime-types.mjs", import.meta.url).pathname },
|
{ 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
|
// Plugin installs never happen in the workerd profile (plugin discovery
|
||||||
// never happen in the workerd profile (plugin discovery is precompiled-only),
|
// is precompiled-only), so mock the package installation toolchain.
|
||||||
// and @npmcli/config touches process.stdout.isTTY during module init.
|
|
||||||
{ find: /^@npmcli\/config(\/.*)?$/, replacement: mockProxy },
|
|
||||||
{ find: /^@npmcli\/arborist(\/.*)?$/, replacement: mockProxy },
|
{ find: /^@npmcli\/arborist(\/.*)?$/, replacement: mockProxy },
|
||||||
{ find: /^pacote(\/.*)?$/, replacement: mockProxy },
|
{ find: /^pacote(\/.*)?$/, replacement: mockProxy },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user