Compare commits

..
Author SHA1 Message Date
LukeParkerDev 5e0750282a refactor(browser): separate connection and tool ownership 2026-09-03 19:53:48 +10:00
LukeParkerDev ea71b0400b fix(browser): return malformed capture errors as tool failures 2026-09-03 19:53:47 +10:00
LukeParkerDev d0f23fa230 fix(browser): explain recovery from tool and transfer failures 2026-09-03 19:53:46 +10:00
LukeParkerDev d5887fc5c4 docs(browser): specify screenshot visibility requirements 2026-09-03 19:53:46 +10:00
LukeParkerDev 52964dda33 refactor(browser): keep contracts inside the plugin package 2026-09-03 19:53:45 +10:00
LukeParkerDev b5d423546b docs(browser): clarify capture and remote deployment limits 2026-09-03 19:53:44 +10:00
LukeParkerDev a79594c72d fix(browser): preserve transferred file names 2026-09-03 19:53:43 +10:00
LukeParkerDev fba7aaaabb fix(browser): tighten wire contracts and file handling 2026-09-03 19:53:43 +10:00
LukeParkerDev c5d6150fe9 feat(browser): define tab-targeted tools and RPC file transfers 2026-09-03 19:53:42 +10:00
LukeParkerDev 71cece1a99 feat(browser): expose the browser through Code Mode 2026-09-03 19:53:41 +10:00
LukeParkerDev 60ab72d884 fix(sdk): resolve packaged worker paths on Windows 2026-09-03 19:53:41 +10:00
LukeParkerDev cde1fc4c6f fix(plugin-browser): keep release helpers out of the package 2026-09-03 19:53:40 +10:00
LukeParkerDev c026023822 refactor(browser): extract the plugin into its own package 2026-09-03 19:53:39 +10:00
LukeParkerDev 952387176e fix(browser): let a newer attachment replace a stale one 2026-09-03 19:53:38 +10:00
LukeParkerDev 0b980a1ac0 refactor(browser): use canonical public schemas 2026-09-03 19:53:38 +10:00
LukeParkerDev a73f76b368 refactor(browser): defer permission enforcement 2026-09-03 19:53:37 +10:00
LukeParkerDev 23b6318fd6 refactor(browser): colocate the public API plugin 2026-09-03 19:53:36 +10:00
LukeParkerDev 6f6fc10847 feat(browser): add public RPC browser plugin 2026-09-03 19:53:35 +10:00
160 changed files with 2175 additions and 1911 deletions
+19
View File
@@ -358,6 +358,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -605,6 +606,21 @@
"solid-js",
],
},
"packages/plugin-browser": {
"name": "@opencode-ai/plugin-browser",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/posts": {
"name": "@opencode-ai/posts",
"dependencies": {
@@ -674,6 +690,7 @@
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
@@ -2144,6 +2161,8 @@
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/plugin-browser": ["@opencode-ai/plugin-browser@workspace:packages/plugin-browser"],
"@opencode-ai/posts": ["@opencode-ai/posts@workspace:packages/posts"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
+23 -4
View File
@@ -423,10 +423,14 @@ export interface ParserState {
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedTools: ReadonlySet<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -1038,12 +1042,16 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
const item = event.item
if (!item) return [state, NO_EVENTS]
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== item.id) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== item.id)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -1056,6 +1064,7 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
{
...state,
lifecycle,
completedMessages,
message: {
id: item.id,
phase: phase === undefined && state.message?.id === item.id ? state.message.phase : phase,
@@ -1085,7 +1094,7 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
]
}
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined || state.completedTools.has(item.id)) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
@@ -1189,9 +1198,14 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
}
if (item.type === "message") {
const active = state.message?.id === item.id
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
if (state.message !== undefined && state.message.id !== item.id)
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
const message = state.message
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined && active ? state.message?.phase : itemPhase
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
const content: string[] = []
for (const part of parts) {
@@ -1207,7 +1221,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
message: active ? undefined : state.message,
completedMessages,
message: undefined,
},
events,
] satisfies StepResult
@@ -1215,6 +1230,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
if (state.completedTools.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
@@ -1241,6 +1257,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
completedTools: new Set([...state.completedTools, item.id]),
},
events,
] satisfies StepResult
@@ -1501,9 +1518,11 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
providerMetadataKey: metadataKey(request.model),
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
completedTools: new Set<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
@@ -82,6 +82,32 @@ describe("Open Responses completed item text", () => {
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
it.effect("assembles a done-only message once across replayed item events", () =>
Effect.gen(function* () {
const item = {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Recovered" }],
}
const response = yield* generate(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
completed,
)
expect(response.text).toBe("Recovered")
expect(response.message.content).toEqual([
{
type: "text",
text: "Recovered",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
})
describe("Open Responses completed item reasoning", () => {
@@ -217,7 +217,7 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves non-empty done-only message content", () =>
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
Effect.gen(function* () {
const text = {
type: "message",
@@ -230,11 +230,17 @@ describe("Open Responses basic-item lifecycles", () => {
content: [{ type: "refusal", refusal: "Done-only refusal." }],
}
const events = yield* collect(
{ type: "response.output_item.done", item: text },
{ type: "response.output_item.done", item: text },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
},
{ type: "response.output_item.done", item: refusal },
{ type: "response.output_item.done", item: refusal },
completed,
)
@@ -266,6 +272,63 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("treats a repeated message lifecycle as replay", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
completed,
)
expect(events.filter(LLMEvent.is.textEnd)).toEqual([
{
type: "text-end",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
}),
)
it.effect("ignores a stale done-only message while another message is active", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
},
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Draft" },
{
type: "text-end",
id: "msg_1",
text: "Final",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
}),
)
// Captured from Bedrock Mantle (openai.gpt-oss-120b): the terminal function_call
// items rename `id` to `item_id` and carry a stray `output_index`.
it.effect("recovers a terminal function_call id from its output slot", () =>
@@ -356,7 +419,7 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("opens and closes a done-only tool", () =>
it.effect("opens and closes a done-only tool once", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
@@ -365,7 +428,12 @@ describe("Open Responses basic-item lifecycles", () => {
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect({ type: "response.output_item.done", item }, completed)
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
@@ -2850,7 +2850,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("ignores duplicate item start events", () =>
it.effect("ignores duplicate item boundary events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
@@ -2881,6 +2881,21 @@ describe("OpenAI Responses route", () => {
arguments: '{"query":"weather"}',
},
},
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
// A completed item that is re-added stays closed.
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
@@ -78,13 +78,9 @@ for (const theme of ["light", "dark"] as const) {
await expectToken(
message,
"background-color",
scenario.accent ? "--v2-background-bg-accent" : theme === "light" ? "--v2-blue-100" : "--v2-blue-1200",
)
await expectToken(
message,
"color",
scenario.accent ? "--v2-text-text-contrast" : theme === "light" ? "--v2-blue-700" : "--v2-blue-300",
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
)
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
})
}
+1 -2
View File
@@ -26,8 +26,7 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), "↵"]}
exitShellKeybind={[formatKeybind("esc", language.t)]}
alternateKeybind={[formatKeybind("mod", language.t), formatKeybind("enter", language.t)]}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
@@ -6,7 +6,3 @@
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
background: var(--v2-background-bg-layer-01);
}
[data-color-scheme="dark"] [data-component="composer-suggestions"] [data-active] {
background: var(--v2-alpha-light-10);
}
+2 -23
View File
@@ -47,7 +47,6 @@ export type ComposerEditorProps = {
attachKeybind?: string[]
attachShortcut?: string
alternateKeybind?: string[]
exitShellKeybind?: string[]
}
export function ComposerEditor(props: ComposerEditorProps) {
@@ -282,24 +281,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</Show>
<Show when={state.mode === "shell"}>
<Button
data-action="composer-exit-shell"
type="button"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5"
onClick={() => {
props.controller.dispatch({ type: "mode.normal" })
props.controller.restoreFocus()
}}
>
{i18n.t("ui.promptInput.exitShell")}
<span class="hidden sm:block">
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
</span>
</Button>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
@@ -692,7 +673,6 @@ export function ComposerEditorPopover(props: {
}) {
return (
<div
data-component="composer-suggestions"
class="absolute inset-x-0 -top-2 z-40 flex max-h-80 -translate-y-full flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
onMouseDown={(event) => event.preventDefault()}
>
@@ -721,7 +701,6 @@ export function ComposerEditorPopover(props: {
<button
type="button"
data-suggestion-id={item.id}
data-active={props.activeID === item.id ? "" : undefined}
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
onPointerMove={() => props.onActiveChange(item)}
@@ -770,9 +749,9 @@ function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorMode
ref={setButton}
data-action="composer-alternate-delivery"
type="button"
variant="ghost-faint"
variant="ghost-muted"
size="small"
class="me-3 gap-1.5 px-1.5 ![font-weight:530] duration-150 motion-reduce:animate-none"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530] duration-150 motion-reduce:animate-none"
classList={{
"animate-in fade-in": presence.animate() && presence.show(),
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
+1 -15
View File
@@ -3,8 +3,7 @@ import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo, startTransition } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createEffect, createMemo } from "solid-js"
export function createHomeController() {
const layout = useLayout()
@@ -46,18 +45,6 @@ export function createHomeController() {
void tabs.newDraft({ server: ServerConnection.key(conn), directory })
}
function openProjectSession(conn: ServerConnection.Any, directory: string, session: SessionInfo) {
const ctx = global.ensureServerCtx(conn)
void ctx.data.session.message.sync(session.id).catch(() => undefined)
void startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
tabs.select(tab)
ctx.data.session.remember(session)
ctx.projects.open(directory)
ctx.projects.touch(directory)
})
}
return {
selection: {
value: selection,
@@ -118,7 +105,6 @@ export function createHomeController() {
openProjectNewSession(conn, project.worktree)
},
openProjectNewSession,
openProjectSession,
},
}
}
@@ -14,7 +14,6 @@ import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
@@ -80,35 +79,6 @@ export function createHomeProjectsController(home: HomeController) {
select: home.project.select,
add: home.project.add,
openNewSession: home.project.openProjectNewSession,
canImportSession: !!platform.openAttachmentPickerDialog,
importSession: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.openAttachmentPickerDialog) return
void platform
.openAttachmentPickerDialog(
{
title: language.t("command.session.import"),
accept: ["application/json"],
extensions: ["json"],
},
async (file) => {
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
await file.text(),
)
const api = home.server.context(conn).sdk.api.session
const imported = await api.import({
...Schema.encodeSync(SessionTransfer.Data)(data),
location: { directory: project.worktree },
} as Parameters<typeof api.import>[0])
home.project.openProjectSession(conn, project.worktree, imported)
},
)
.catch((cause: unknown) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
})
})
},
edit: (conn: ServerConnection.Any, project: LocalProject) => {
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
@@ -37,8 +37,6 @@ export function HomeProjects(props: {
onSelectProject={props.projects.project.select}
onAddProjects={props.projects.project.add}
onOpenProjectNewSession={props.projects.project.openNewSession}
canImportSession={props.projects.project.canImportSession}
onImportSession={props.projects.project.importSession}
onEditProject={props.projects.project.edit}
onRevealProject={props.projects.project.reveal}
onClearNotifications={props.projects.project.clearNotifications}
-7
View File
@@ -57,8 +57,6 @@ export type HomeProjectsViewProps = {
onSelectProject: (server: ServerConnection.Any, directory: string) => void
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void
canImportSession: boolean
onImportSession: (server: ServerConnection.Any, project: LocalProject) => void
onEditProject: (server: ServerConnection.Any, project: LocalProject) => void
onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void
onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
@@ -672,11 +670,6 @@ function HomeProjectRow(
<Menu.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</Menu.Item>
<Show when={props.canImportSession}>
<Menu.Item onSelect={() => props.onImportSession(props.server, props.project)}>
{props.language.t("command.session.import")}
</Menu.Item>
</Show>
<Menu.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</Menu.Item>
@@ -21,8 +21,7 @@ import { errorMessage } from "@/shell/layout/helpers"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { removedSessionIDs } from "@/session/session-domain"
import { pathKey } from "@/workspaces/path-key"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { usePlatform } from "@/runtime/platform/platform"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
@@ -46,7 +45,6 @@ export function createHomeSessionsController(home: HomeController) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const queryClient = useQueryClient()
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
@@ -174,7 +172,7 @@ export function createHomeSessionsController(home: HomeController) {
try {
const data = await fetchSessionExport({ sessionID: session.id, api: ctx.sdk.api })
const filename = sessionExportFilename(data.info)
if (!(await saveSessionExport(filename, data, platform))) return
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "ሳይክል ቋንቋ",
"command.language.set": "ቋንቋን ተጠቀም፡ {{language}}",
"command.session.new": "አዲስ ክፍለ ጊዜ",
"command.session.import": "ክፍለ ጊዜ ማስመጣት",
"command.file.open": "ክፍት ፋይል",
"command.tab.close": "ትርፉን ዝጋ",
"command.tab.reopenClosed": "የተዘጋውን ትር እንደገና ክፈት",
-1
View File
@@ -139,7 +139,6 @@ export const dict = {
"command.language.cycle": "تغيير اللغة",
"command.language.set": "استخدام اللغة: {{language}}",
"command.session.new": "جلسة جديدة",
"command.session.import": "استيراد جلسة",
"command.file.open": "فتح ملف",
"command.tab.close": "إغلاق علامة التبويب",
"command.tab.reopenClosed": "إعادة فتح علامة التبويب المغلقة",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Dili dəyiş",
"command.language.set": "Dildən istifadə et: {{language}}",
"command.session.new": "Yeni sessiya",
"command.session.import": "Sessiyanı idxal et",
"command.file.open": "Faylı aç",
"command.tab.close": "Tabı bağla",
"command.tab.reopenClosed": "Bağlanmış tabı yenidən aç",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Цикличен език",
"command.language.set": "Използвайте език: {{language}}",
"command.session.new": "Нова сесия",
"command.session.import": "Импортиране на сесия",
"command.file.open": "Отворете файла",
"command.tab.close": "Затваряне на раздела",
"command.tab.reopenClosed": "Повторно отваряне на затворен раздел",
-1
View File
@@ -134,7 +134,6 @@ export const dict: Record<string, string> = {
"command.language.cycle": "সাইকেল ভাষা",
"command.language.set": "ভাষা ব্যবহার করুন: {{language}}",
"command.session.new": "নতুন সেশন",
"command.session.import": "সেশন আমদানি করুন",
"command.file.open": "ফাইল খুলুন",
"command.tab.close": "ট্যাব বন্ধ করুন",
"command.tab.reopenClosed": "বন্ধ ট্যাব আবার খুলুন",
-1
View File
@@ -141,7 +141,6 @@ export const dict = {
"command.language.cycle": "Alternar idioma",
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nova sessão",
"command.session.import": "Importar sessão",
"command.file.open": "Abrir arquivo",
"command.tab.close": "Fechar aba",
"command.tab.reopenClosed": "Reabrir aba fechada",
-1
View File
@@ -147,7 +147,6 @@ export const dict = {
"command.language.set": "Koristi jezik: {{language}}",
"command.session.new": "Nova sesija",
"command.session.import": "Uvezi sesiju",
"command.file.open": "Otvori datoteku",
"command.tab.close": "Zatvori karticu",
"command.tab.reopenClosed": "Ponovo otvori zatvorenu karticu",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Llenguatge de cicle",
"command.language.set": "Utilitza l'idioma: {{language}}",
"command.session.new": "Nova sessió",
"command.session.import": "Importa la sessió",
"command.file.open": "Obre el fitxer",
"command.tab.close": "Tanca la pestanya",
"command.tab.reopenClosed": "Torneu a obrir la pestanya tancada",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Jazyk cyklu",
"command.language.set": "Použít jazyk: {{language}}",
"command.session.new": "Nová relace",
"command.session.import": "Importovat relaci",
"command.file.open": "Otevřít soubor",
"command.tab.close": "Zavřít kartu",
"command.tab.reopenClosed": "Znovu otevřete zavřenou kartu",
-1
View File
@@ -46,7 +46,6 @@ export const dict = {
"command.language.set": "Brug sprog: {{language}}",
"command.session.new": "Ny session",
"command.session.import": "Importer session",
"command.file.open": "Åbn fil",
"command.tab.close": "Luk fane",
"command.tab.reopenClosed": "Åbn lukket fane igen",
-1
View File
@@ -44,7 +44,6 @@ export const dict = {
"command.language.cycle": "Sprache wechseln",
"command.language.set": "Sprache verwenden: {{language}}",
"command.session.new": "Neue Sitzung",
"command.session.import": "Sitzung importieren",
"command.file.open": "Datei öffnen",
"command.tab.close": "Tab schließen",
"command.tab.reopenClosed": "Geschlossenen Tab wieder öffnen",
-1
View File
@@ -136,7 +136,6 @@ export const dict = {
"command.language.cycle": "ސައިކަލް ބަސް",
"command.language.set": "ބަސް ބޭނުންކުރުން: {{language}}",
"command.session.new": "އާ ޖަލްސާއެއް",
"command.session.import": "ޖަލްސާ އިމްޕޯޓް ކުރައްވާ",
"command.file.open": "ފައިލް ހުޅުވާލާށެވެ",
"command.tab.close": "ޓެބް ބަންދުކުރުން",
"command.tab.reopenClosed": "ބަންދުކޮށްފައިވާ ޓެބް އަލުން ހުޅުވާލާށެވެ",
-1
View File
@@ -136,7 +136,6 @@ export const dict: Record<string, string> = {
"command.language.cycle": "འཁོར་བའི་སྐད་ཡིག།",
"command.language.set": "སྐད་ཡིག་ལག་ལེན་འཐབ།: {{language}}",
"command.session.new": "ལཱ་ཡུན་གསརཔ།",
"command.session.import": "ལཱ་ཡུན་ནང་འདྲེན།",
"command.file.open": "ཡིག་སྣོད་ཁ་ཕྱེ།",
"command.tab.close": "མཆོང་ལྡེ་ཁ་བསྡམས།",
"command.tab.reopenClosed": "ཁ་བསྡམས་ཡོད་པའི་མཆོང་ལྡེ་ལོག་ཁ་ཕྱེ།",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "Γλώσσα κύκλου",
"command.language.set": "Γλώσσα χρήσης: {{language}}",
"command.session.new": "Νέα συνεδρία",
"command.session.import": "Εισαγωγή συνεδρίας",
"command.file.open": "Άνοιγμα αρχείου",
"command.tab.close": "Κλείσιμο καρτέλας",
"command.tab.reopenClosed": "Άνοιγμα ξανά κλειστής καρτέλας",
-4
View File
@@ -100,7 +100,6 @@ export const dict = {
"command.session.fork.description": "Create a new session from a previous message",
"command.session.export": "Export session",
"command.session.export.description": "Export the full session transcript as JSON",
"command.session.import": "Import session",
"command.session.copyID": "Copy Session ID",
"palette.search.placeholder": "Search files, commands, and sessions",
@@ -676,14 +675,11 @@ export const dict = {
"session.error.incompatible.description":
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.moveRunning": "Move running work to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
"session.background.runningCount.one": "{{count}} item running in background",
"session.background.runningCount.other": "{{count}} items running in background",
"session.background.tasksRunning.one": "{{count}} background task running",
"session.background.tasksRunning.other": "{{count}} background tasks running",
"session.background.combine": "{{first}} and {{second}}",
"session.background.shell.one": "{{count}} shell",
"session.background.shell.other": "{{count}} shells",
-1
View File
@@ -147,7 +147,6 @@ export const dict = {
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nueva sesión",
"command.session.import": "Importar sesión",
"command.file.open": "Abrir archivo",
"command.tab.close": "Cerrar pestaña",
"command.tab.reopenClosed": "Reabrir pestaña cerrada",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Tsükli keel",
"command.language.set": "Kasuta keelt: {{language}}",
"command.session.new": "Uus seanss",
"command.session.import": "Impordi seanss",
"command.file.open": "Ava fail",
"command.tab.close": "Sule vahekaart",
"command.tab.reopenClosed": "Ava suletud vaheleht uuesti",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "زبان چرخه",
"command.language.set": "استفاده از زبان: {{language}}",
"command.session.new": "جلسه جدید",
"command.session.import": "وارد کردن جلسه",
"command.file.open": "باز کردن فایل",
"command.tab.close": "بستن برگه",
"command.tab.reopenClosed": "برگه بسته را دوباره باز کنید",
-1
View File
@@ -40,7 +40,6 @@ export const dict = {
"command.language.cycle": "Vaihda kieltä",
"command.language.set": "Käytä kieltä: {{language}}",
"command.session.new": "Uusi istunto",
"command.session.import": "Tuo istunto",
"command.file.open": "Avaa tiedosto",
"command.tab.close": "Sulje välilehti",
"command.tab.reopenClosed": "Avaa suljettu välilehti uudelleen",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Súkklumál",
"command.language.set": "Brúka mál: {{language}}",
"command.session.new": "Nýggj setan",
"command.session.import": "Innflyt setan",
"command.file.open": "Opna fíluna",
"command.tab.close": "Lat flipan aftur",
"command.tab.reopenClosed": "Opna aftur stongdan flipan",
-1
View File
@@ -141,7 +141,6 @@ export const dict = {
"command.language.cycle": "Changer de langue",
"command.language.set": "Utiliser la langue : {{language}}",
"command.session.new": "Nouvelle session",
"command.session.import": "Importer une session",
"command.file.open": "Ouvrir un fichier",
"command.tab.close": "Fermer l'onglet",
"command.tab.reopenClosed": "Rouvrir l'onglet fermé",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "מעבר לשפה הבאה",
"command.language.set": "השתמש בשפה: {{language}}",
"command.session.new": "הפעלה חדשה",
"command.session.import": "ייבוא הפעלה",
"command.file.open": "פתח את הקובץ",
"command.tab.close": "סגור כרטיסייה",
"command.tab.reopenClosed": "פתח מחדש את הכרטיסייה הסגורה",
-1
View File
@@ -140,7 +140,6 @@ export const dict = {
"command.language.cycle": "भाषा बदलें",
"command.language.set": "भाषा का प्रयोग करें: {{language}}",
"command.session.new": "नया सेशन",
"command.session.import": "सेशन आयात करें",
"command.file.open": "फ़ाइल खोलें",
"command.tab.close": "टैब बंद करें",
"command.tab.reopenClosed": "बंद टैब पुनः खोलें",
-1
View File
@@ -137,7 +137,6 @@ export const dict = {
"command.language.cycle": "Promijeni jezik",
"command.language.set": "Koristite jezik: {{language}}",
"command.session.new": "Nova sesija",
"command.session.import": "Uvezi sesiju",
"command.file.open": "Otvori datoteku",
"command.tab.close": "Zatvori karticu",
"command.tab.reopenClosed": "Ponovno otvori zatvorenu karticu",
-1
View File
@@ -137,7 +137,6 @@ export const dict = {
"command.language.cycle": "Nyelv váltása",
"command.language.set": "Nyelv használata: {{language}}",
"command.session.new": "Új munkamenet",
"command.session.import": "Munkamenet importálása",
"command.file.open": "Nyissa meg a fájlt",
"command.tab.close": "Lap bezárása",
"command.tab.reopenClosed": "Nyissa meg újra a bezárt lapot",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Ցիկլի լեզու",
"command.language.set": "Օգտագործել լեզուն՝ {{language}}",
"command.session.new": "Նոր նիստ",
"command.session.import": "Ներմուծել նիստը",
"command.file.open": "Բացել ֆայլ",
"command.tab.close": "Փակել ներդիրը",
"command.tab.reopenClosed": "Վերաբացել փակ ներդիրը",
-1
View File
@@ -147,7 +147,6 @@ export const dict = {
"command.language.set": "Gunakan bahasa: {{language}}",
"command.session.new": "Sesi baru",
"command.session.import": "Impor sesi",
"command.file.open": "Buka berkas",
"command.tab.close": "Tutup tab",
"command.tab.reopenClosed": "Buka kembali tab yang ditutup",
-1
View File
@@ -137,7 +137,6 @@ export const dict = {
"command.language.cycle": "Skipta um tungumál",
"command.language.set": "Notaðu tungumál: {{language}}",
"command.session.new": "Ný seta",
"command.session.import": "Flytja inn setu",
"command.file.open": "Opna skrá",
"command.tab.close": "Loka flipa",
"command.tab.reopenClosed": "Opnaðu aftur lokaðan flipa",
-1
View File
@@ -41,7 +41,6 @@ export const dict = {
"command.language.cycle": "Cambia lingua",
"command.language.set": "Usa la lingua: {{language}}",
"command.session.new": "Nuova sessione",
"command.session.import": "Importa sessione",
"command.file.open": "Apri file",
"command.tab.close": "Chiudi scheda",
"command.tab.reopenClosed": "Riapri la scheda chiusa",
-1
View File
@@ -139,7 +139,6 @@ export const dict = {
"command.language.cycle": "言語の切り替え",
"command.language.set": "言語を使用: {{language}}",
"command.session.new": "新しいセッション",
"command.session.import": "セッションをインポート",
"command.file.open": "ファイルを開く",
"command.tab.close": "タブを閉じる",
"command.tab.reopenClosed": "閉じたタブを再度開く",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "ციკლის ენა",
"command.language.set": "გამოიყენე ენა: {{language}}",
"command.session.new": "ახალი სესია",
"command.session.import": "სესიის იმპორტი",
"command.file.open": "გახსენით ფაილი",
"command.tab.close": "ჩანართის დახურვა",
"command.tab.reopenClosed": "დახურული ჩანართის ხელახლა გახსნა",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "ភាសាវដ្ត",
"command.language.set": "ប្រើភាសា៖ {{language}}",
"command.session.new": "សម័យថ្មី។",
"command.session.import": "នាំចូលសម័យ",
"command.file.open": "បើកឯកសារ",
"command.tab.close": "បិទផ្ទាំង",
"command.tab.reopenClosed": "បើកផ្ទាំងបិទឡើងវិញ",
-1
View File
@@ -37,7 +37,6 @@ export const dict = {
"command.language.cycle": "언어 순환",
"command.language.set": "언어 사용: {{language}}",
"command.session.new": "새 세션",
"command.session.import": "세션 가져오기",
"command.file.open": "파일 열기",
"command.tab.close": "탭 닫기",
"command.context.addSelection": "선택 영역을 컨텍스트에 추가",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "ພາສາຮອບວຽນ",
"command.language.set": "ໃຊ້ພາສາ: {{language}}",
"command.session.new": "ເຊດຊັນໃໝ່",
"command.session.import": "ນຳເຂົ້າເຊດຊັນ",
"command.file.open": "ເປີດໄຟລ໌",
"command.tab.close": "ປິດແຖບ",
"command.tab.reopenClosed": "ເປີດແຖບປິດຄືນໃໝ່",
-1
View File
@@ -137,7 +137,6 @@ export const dict = {
"command.language.cycle": "Perjungti kalbą",
"command.language.set": "Naudokite kalbą: {{language}}",
"command.session.new": "Naujas seansas",
"command.session.import": "Importuoti seansą",
"command.file.open": "Atidaryti failą",
"command.tab.close": "Uždaryti skirtuką",
"command.tab.reopenClosed": "Iš naujo atidaryti uždarytą skirtuką",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Mainīt valodu",
"command.language.set": "Izmantot valodu: {{language}}",
"command.session.new": "Jauna sesija",
"command.session.import": "Importēt sesiju",
"command.file.open": "Atvērt failu",
"command.tab.close": "Aizvērt cilni",
"command.tab.reopenClosed": "Atvērt aizvērtu cilni",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "Јазик на циклус",
"command.language.set": "Користете јазик: {{language}}",
"command.session.new": "Нова сесија",
"command.session.import": "Увези сесија",
"command.file.open": "Отворете ја датотеката",
"command.tab.close": "Затвори ја картичката",
"command.tab.reopenClosed": "Повторно отворете го затворениот таб",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Циклийн хэл",
"command.language.set": "Хэл ашиглах: {{language}}",
"command.session.new": "Шинэ сесс",
"command.session.import": "Сесс импортлох",
"command.file.open": "Файлыг нээх",
"command.tab.close": "Табыг хаах",
"command.tab.reopenClosed": "Хаагдсан табыг дахин нээнэ үү",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Tukar bahasa",
"command.language.set": "Guna bahasa: {{language}}",
"command.session.new": "Sesi baharu",
"command.session.import": "Import sesi",
"command.file.open": "Buka fail",
"command.tab.close": "Tutup tab",
"command.tab.reopenClosed": "Buka semula tab tertutup",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "စက်ဝိုင်းဘာသာစကား",
"command.language.set": "ဘာသာစကားကို အသုံးပြုပါ- {{language}}",
"command.session.new": "စက်ရှင်အသစ်",
"command.session.import": "စက်ရှင် တင်သွင်းရန်",
"command.file.open": "ဖိုင်ကိုဖွင့်ပါ။",
"command.tab.close": "တဘ်ကို ပိတ်ပါ။",
"command.tab.reopenClosed": "ပိတ်ထားသော တက်ဘ်ကို ပြန်ဖွင့်ပါ။",
-1
View File
@@ -134,7 +134,6 @@ export const dict: Record<string, string> = {
"command.language.cycle": "साइकल भाषा",
"command.language.set": "भाषा प्रयोग गर्नुहोस्: {{language}}",
"command.session.new": "नयाँ सत्र",
"command.session.import": "सत्र आयात गर्नुहोस्",
"command.file.open": "फाइल खोल्नुहोस्",
"command.tab.close": "ट्याब बन्द गर्नुहोस्",
"command.tab.reopenClosed": "बन्द ट्याब पुन: खोल्नुहोस्",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Volgende taal",
"command.language.set": "Gebruik taal: {{language}}",
"command.session.new": "Nieuwe sessie",
"command.session.import": "Sessie importeren",
"command.file.open": "Bestand openen",
"command.tab.close": "Tabblad sluiten",
"command.tab.reopenClosed": "Gesloten tabblad opnieuw openen",
-1
View File
@@ -146,7 +146,6 @@ export const dict = {
"command.language.set": "Bruk språk: {{language}}",
"command.session.new": "Ny sesjon",
"command.session.import": "Importer sesjon",
"command.file.open": "Åpne fil",
"command.tab.close": "Lukk fane",
"command.context.addSelection": "Legg til markering i kontekst",
-1
View File
@@ -139,7 +139,6 @@ export const dict = {
"command.language.cycle": "اگلی بولی ورتو",
"command.language.set": "بولی ورتو: {{language}}",
"command.session.new": "نواں سیشن",
"command.session.import": "سیشن درآمد کرو",
"command.file.open": "فائل کھولو",
"command.tab.close": "ٹیب بند کرو",
"command.tab.reopenClosed": "بند ٹیب دوبارہ کھولو",
-1
View File
@@ -140,7 +140,6 @@ export const dict = {
"command.language.cycle": "Przełącz język",
"command.language.set": "Użyj języka: {{language}}",
"command.session.new": "Nowa sesja",
"command.session.import": "Importuj sesję",
"command.file.open": "Otwórz plik",
"command.tab.close": "Zamknij kartę",
"command.tab.reopenClosed": "Otwórz ponownie zamkniętą kartę",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Schimbă limba",
"command.language.set": "Folosește limba: {{language}}",
"command.session.new": "Sesiune nouă",
"command.session.import": "Importă sesiunea",
"command.file.open": "Deschide fișier",
"command.tab.close": "Închide fila",
"command.tab.reopenClosed": "Redeschide fila închisă",
-1
View File
@@ -146,7 +146,6 @@ export const dict = {
"command.language.set": "Использовать язык: {{language}}",
"command.session.new": "Новая сессия",
"command.session.import": "Импортировать сессию",
"command.file.open": "Открыть файл",
"command.tab.close": "Закрыть вкладку",
"command.tab.reopenClosed": "Повторно открыть закрытую вкладку",
-1
View File
@@ -133,7 +133,6 @@ export const dict: Record<string, string> = {
"command.language.cycle": "චක්‍ර භාෂාව",
"command.language.set": "භාෂාව භාවිතා කරන්න: {{language}}",
"command.session.new": "නව සැසිය",
"command.session.import": "සැසිය ආනයනය කරන්න",
"command.file.open": "ගොනුව විවෘත කරන්න",
"command.tab.close": "ටැබ් එක වසන්න",
"command.tab.reopenClosed": "වසා දැමූ ටැබය නැවත විවෘත කරන්න",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Prepnúť jazyk",
"command.language.set": "Použiť jazyk: {{language}}",
"command.session.new": "Nová relácia",
"command.session.import": "Importovať reláciu",
"command.file.open": "Otvoriť súbor",
"command.tab.close": "Zavrieť kartu",
"command.tab.reopenClosed": "Obnoviť zatvorenú kartu",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Jezik cikla",
"command.language.set": "Uporabi jezik: {{language}}",
"command.session.new": "Nova seja",
"command.session.import": "Uvozi sejo",
"command.file.open": "Odpri datoteko",
"command.tab.close": "Zapri zavihek",
"command.tab.reopenClosed": "Ponovno odpri zaprt zavihek",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "Gjuha e ciklit",
"command.language.set": "Përdorni gjuhën: {{language}}",
"command.session.new": "Sesion i ri",
"command.session.import": "Importo sesionin",
"command.file.open": "Hap skedarin",
"command.tab.close": "Mbyll skedën",
"command.tab.reopenClosed": "Rihap skedën e mbyllur",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "језик циклуса",
"command.language.set": "Користи језик: {{language}}",
"command.session.new": "Нова сесија",
"command.session.import": "Увези сесију",
"command.file.open": "Отворите датотеку",
"command.tab.close": "Затвори картицу",
"command.tab.reopenClosed": "Поново отворите затворену картицу",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "Växla språk",
"command.language.set": "Använd språk: {{language}}",
"command.session.new": "Ny session",
"command.session.import": "Importera session",
"command.file.open": "Öppna filen",
"command.tab.close": "Stäng fliken",
"command.tab.reopenClosed": "Öppna stängd flik igen",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "Забони даврӣ",
"command.language.set": "Истифодаи забон: {{language}}",
"command.session.new": "Сеанси нав",
"command.session.import": "Воридоти сеанс",
"command.file.open": "Файлро кушоед",
"command.tab.close": "Варақаро пӯшед",
"command.tab.reopenClosed": "Варақаи пӯшидаро аз нав кушоед",
-1
View File
@@ -145,7 +145,6 @@ export const dict = {
"command.language.set": "ใช้ภาษา: {{language}}",
"command.session.new": "เซสชันใหม่",
"command.session.import": "นำเข้าเซสชัน",
"command.file.open": "เปิดไฟล์",
"command.tab.close": "ปิดแท็บ",
"command.tab.reopenClosed": "เปิดแท็บที่ปิดไปอีกครั้ง",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "Sikl dili",
"command.language.set": "Dil ulanyň: {{language}}",
"command.session.new": "Täze sessiýa",
"command.session.import": "Sessiýany import et",
"command.file.open": "Faýl açyň",
"command.tab.close": "Salgy ýapyň",
"command.tab.reopenClosed": "Closedapyk goýmany açyň",
-1
View File
@@ -151,7 +151,6 @@ export const dict = {
"command.language.set": "Dil kullan: {{language}}",
"command.session.new": "Yeni oturum",
"command.session.import": "Oturumu içe aktar",
"command.file.open": "Dosya aç",
"command.tab.close": "Sekmeyi kapat",
"command.tab.reopenClosed": "Kapatılan sekmeyi yeniden aç",
-1
View File
@@ -147,7 +147,6 @@ export const dict = {
"command.language.set": "Використати мову: {{language}}",
"command.session.new": "Нова сесія",
"command.session.import": "Імпортувати сесію",
"command.file.open": "Відкрити файл",
"command.tab.close": "Закрити вкладку",
"command.tab.reopenClosed": "Повторно відкрити закриту вкладку",
-1
View File
@@ -141,7 +141,6 @@ export const dict = {
"command.language.cycle": "اگلی زبان منتخب کریں",
"command.language.set": "زبان استعمال کریں: {{language}}",
"command.session.new": "نیا سیشن",
"command.session.import": "سیشن درآمد کریں",
"command.file.open": "فائل کھولیں۔",
"command.tab.close": "ٹیب بند کریں۔",
"command.tab.reopenClosed": "بند ٹیب کو دوبارہ کھولیں۔",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Keyingi til",
"command.language.set": "Tildan foydalaning: {{language}}",
"command.session.new": "Yangi sessiya",
"command.session.import": "Sessiyani import qilish",
"command.file.open": "Faylni ochish",
"command.tab.close": "Tabni yoping",
"command.tab.reopenClosed": "Yopiq tabni qayta oching",
-1
View File
@@ -140,7 +140,6 @@ export const dict = {
"command.language.cycle": "Chuyển ngôn ngữ",
"command.language.set": "Sử dụng ngôn ngữ: {{language}}",
"command.session.new": "Phiên mới",
"command.session.import": "Nhập phiên",
"command.file.open": "Mở tệp",
"command.tab.close": "Đóng tab",
"command.tab.reopenClosed": "Mở lại tab đã đóng",
-1
View File
@@ -154,7 +154,6 @@ export const dict = {
"command.language.set": "使用语言:{{language}}",
"command.session.new": "新建会话",
"command.session.import": "导入会话",
"command.file.open": "打开文件",
-1
View File
@@ -149,7 +149,6 @@ export const dict = {
"command.language.set": "使用語言: {{language}}",
"command.session.new": "新增工作階段",
"command.session.import": "匯入工作階段",
"command.file.open": "開啟檔案",
"command.tab.close": "關閉分頁",
"command.tab.reopenClosed": "重新開啟已關閉的分頁",
@@ -59,8 +59,8 @@ type PlatformBase = {
/** Resolve the native source path for a desktop File. */
getPathForFile?(file: File): string
/** Open a native save file dialog and write content to the selected path (desktop only) */
saveFile?(opts: SaveFilePickerOptions, content: string): Promise<boolean>
/** Open a native save file picker dialog (desktop only) */
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
@@ -1,12 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { AgentListOutput, ModelListOutput, Project, ProviderListOutput } from "@opencode-ai/client/promise"
import {
directoryKey,
normalizeAgentList,
normalizeProjectInfo,
normalizeProviderList,
updateProjectInfo,
} from "./utils"
import { directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => {
test("adapts current agents to the app agent shape", () => {
@@ -116,34 +110,3 @@ describe("directoryKey", () => {
expect(String(directoryKey("/"))).toBe("/")
})
})
describe("updateProjectInfo", () => {
test("applies saved metadata without losing workspace inventory", () => {
const update = {
id: "project",
canonical: "/repo",
name: "Repo",
icon: { color: "purple" },
time: { created: 1, updated: 2 },
sandboxes: ["/repo-sandbox"],
} satisfies Project
expect(
updateProjectInfo(
{
...update,
name: "Old name",
icon: { color: "gray" },
worktree: "/old-repo",
worktrees: [{ directory: "/repo", strategy: "git" }],
},
update,
),
).toMatchObject({
name: "Repo",
icon: { color: "purple" },
worktree: "/repo",
worktrees: [{ directory: "/repo", strategy: "git" }],
})
})
})
@@ -137,12 +137,3 @@ export function normalizeProjectInfo(project: Project | CurrentProject): Project
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
}
}
export function updateProjectInfo(project: Project, update: CurrentProject): Project {
return {
...project,
...update,
worktree: update.canonical,
worktrees: project.worktrees,
}
}
+1 -9
View File
@@ -11,7 +11,7 @@ import type { ProjectMeta } from "./global-sync/types"
import { formatServerError } from "@/runtime/server/errors"
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey, updateProjectInfo } from "./global-sync/utils"
import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/workspaces/path-key"
import type { ServerScope } from "@/runtime/server/scope"
import { persisted } from "@/runtime/persistence/storage"
@@ -215,15 +215,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
return promise
}
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
setProjects((projects) =>
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
)
}
const unsub = serverSDK.event.listen((event) => {
connection.handleEvent({ type: event.type })
if (event.type === "project.updated") applyProjectUpdate(event.data)
if (!event.location) {
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
@@ -250,7 +243,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
})
const projectApi = {
update: applyProjectUpdate,
meta(directory: string, patch: ProjectMeta) {
children.projectMeta(directory, patch)
},
@@ -1,8 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { ServerApi } from "@/runtime/server/api"
import type { Platform } from "@/runtime/platform/platform"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "./export"
import { fetchSessionExport, sessionExportFilename } from "./export"
describe("sessionExportFilename", () => {
test("generates filename from title", () => {
@@ -56,31 +55,3 @@ describe("fetchSessionExport", () => {
expect(fetchSessionExport({ sessionID: "ses_missing", api })).rejects.toThrow("Session not found")
})
})
describe("saveSessionExport", () => {
test("returns false when the native save dialog is cancelled", async () => {
const calls: string[][] = []
const platform: Pick<Platform, "saveFile"> = {
saveFile: async (_options, content) => {
calls.push([content])
return false
},
}
expect(await saveSessionExport("session.json", { id: "ses_1" }, platform)).toBe(false)
expect(calls).toEqual([['{\n "id": "ses_1"\n}']])
})
test("passes serialized data to the native save operation", async () => {
const writes: string[][] = []
const platform: Pick<Platform, "saveFile"> = {
saveFile: async (options, content) => {
writes.push([options.defaultPath ?? "", content])
return true
},
}
expect(await saveSessionExport("session.json", { id: "ses_1" }, platform)).toBe(true)
expect(writes).toEqual([["session.json", '{\n "id": "ses_1"\n}']])
})
})
@@ -1,6 +1,5 @@
import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { ServerApi } from "@/runtime/server/api"
import type { Platform } from "@/runtime/platform/platform"
export type SessionExportData = {
info: SessionInfo
@@ -54,15 +53,3 @@ export function downloadSessionExport(filename: string, data: unknown) {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
export async function saveSessionExport(
filename: string,
data: unknown,
platform: Pick<Platform, "saveFile">,
) {
if (!platform.saveFile) {
downloadSessionExport(filename, data)
return true
}
return platform.saveFile({ defaultPath: filename }, JSON.stringify(data, null, 2))
}
@@ -9,7 +9,7 @@ import { useServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
import { useTerminal } from "@/session/terminal/context"
import { showToast } from "@/shell/notifications/toast"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { usePlatform } from "@/runtime/platform/platform"
import type { SessionModel } from "@/session/model"
import type { SessionRevert } from "@/session/revert"
@@ -104,7 +104,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
api: serverSDK.api,
})
const filename = sessionExportFilename(data.info)
if (!(await saveSessionExport(filename, data, platform))) return
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
@@ -156,10 +156,10 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
data-action="session-queue-steer"
type="button"
size="small"
variant="ghost-faint"
variant="ghost-muted"
icon="arrow-up"
disabled={props.queue.busy()}
class="![font-weight:530]"
class="text-v2-text-text-muted ![font-weight:530]"
onClick={() => void props.queue.steer(props.id)}
>
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
@@ -12,9 +12,8 @@ import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { showToast } from "@/shell/notifications/toast"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useProviders } from "@/providers/catalog/providers"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
@@ -85,7 +84,6 @@ const emptyMessages: SessionMessageInfo[] = []
export function SessionContextTab() {
const data = useData()
const language = useLanguage()
const platform = usePlatform()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory)
@@ -201,7 +199,7 @@ export function SessionContextTab() {
api: serverSDK.api,
})
const filename = sessionExportFilename(data.info)
if (!(await saveSessionExport(filename, data, platform))) return
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
@@ -15,9 +15,8 @@ import { removedSessionIDs } from "@/session/session-domain"
import { useServerSDK } from "@/runtime/server/client"
import { sessionHref } from "@/shell/routes/session"
import { sessionTitle } from "@/session/title"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { showToast } from "@/shell/notifications/toast"
import { usePlatform } from "@/runtime/platform/platform"
import { applyTimelineMessageHandoff, timelineChildTitle, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/runtime/server/current"
@@ -56,7 +55,6 @@ export function createTimelineController(input: { session: TimelineSessionSource
const tabs = useTabs()
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const handedOffMessages = createMemo(() =>
applyTimelineMessageHandoff(
input.session.history.messages(),
@@ -172,7 +170,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
try {
const data = await fetchSessionExport({ sessionID: id, api: serverSDK.api })
const filename = sessionExportFilename(data.info)
if (!(await saveSessionExport(filename, data, platform))) return
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
@@ -2,7 +2,7 @@ import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, type
import { createStore } from "solid-js/store"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import type { SessionUserActions } from "@opencode-ai/session-ui/actions"
import { Button } from "@opencode-ai/ui/button"
import { Badge } from "@opencode-ai/ui/badge"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -47,42 +47,32 @@ type SessionBackground = {
move: () => Promise<void>
}
export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => void }) {
export function BackgroundMoveHint(props: { keybind?: string[] }) {
const language = useLanguage()
const command = useCommand()
const marker = "__OPENCODE_BACKGROUND_KEYBIND__"
const parts = createMemo(() => language.t("session.background.moveInline", { keybind: marker }).split(marker))
const keys = () => props.keybind ?? command.keybindParts("session.background")
const keybind = () => props.keybind?.join("+") ?? command.keybind("session.background")
return (
<Button
<div
data-component="session-background-hint"
type="button"
variant="ghost-faint"
size="small"
icon="outline-arrow-to-corner-top-right"
class="max-w-full"
class="flex h-6 max-w-full items-center justify-center gap-[3px] overflow-hidden text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
onClick={() => props.onMove?.()}
>
<span class="min-w-0 truncate">{language.t("session.background.moveRunning")}</span>
<span data-slot="session-background-hint-prefix" class="shrink-0">
{parts()[0].trim()}
</span>
<Keybind keys={keys()} variant="neutral" />
</Button>
<span class="min-w-0 truncate">{parts()[1].trim()}</span>
</div>
)
}
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
const language = useLanguage()
const [open, setOpen] = createSignal(false)
const [triggerRef, setTriggerRef] = createSignal<HTMLButtonElement>()
const tasks = createMemo<BackgroundTask[]>((previous = []) => (props.tasks.length > 0 ? props.tasks : previous))
const presence = createAnimatedPresence(
() => (props.tasks.length > 0 ? true : undefined),
() => triggerRef() ?? null,
)
createEffect(() => {
if (props.tasks.length > 0) return
setOpen(false)
})
const taskType = (task: BackgroundTask) => {
if (task.type === "shell") return language.t("ui.tool.shell")
if (!task.agent) return language.t("ui.tool.agent.default")
@@ -94,38 +84,31 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?:
open={open()}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
gutter={4}
onOpenChange={(value) => setOpen(value && props.tasks.length > 0)}
onOpenChange={setOpen}
>
<Show when={presence.present()}>
<Popover.Trigger
ref={setTriggerRef}
as="button"
type="button"
data-component="session-background-summary"
class="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 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 duration-150 motion-reduce:animate-none"
classList={{
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
}}
aria-label={language.plural("session.background.tasksRunning", tasks().length)}
>
<Icon
name="outline-arrow-to-corner-top-right"
class="shrink-0 text-v2-icon-icon-muted"
/>
<TextShimmer
as="span"
text={language.plural("session.background.tasksRunning", tasks().length)}
active
class="min-w-0 flex-1 truncate text-start"
/>
</Popover.Trigger>
</Show>
<Popover.Trigger
as="button"
type="button"
data-component="session-background-summary"
class="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 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"
aria-label={language.plural("session.background.runningCount", props.tasks.length)}
>
<Badge class="!w-4 !px-0 !border-v2-border-border-strong !bg-v2-background-bg-layer-03">
{props.tasks.length}
</Badge>
<TextShimmer
as="span"
text={language.t("session.background.running")}
active
class="min-w-0 flex-1 truncate text-start"
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
data-component="session-background-list"
class="z-[60] w-[200px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] outline-none data-[closed]:animate-out data-[closed]:fade-out data-[closed]:duration-150 motion-reduce:data-[closed]:animate-none"
class="z-[60] w-[200px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] outline-none"
>
<For each={tasks().slice(0, 10)}>
<For each={props.tasks.slice(0, 10)}>
{(task) => (
<div
data-component="session-background-list-item"
@@ -270,7 +253,7 @@ export function SessionSummaryPanel(props: {
when={props.branch}
fallback={
<span class="flex min-w-0 items-center gap-1.5">
<span class="shrink-0 whitespace-nowrap">{language.t("session.summary.noBranch")}</span>
<span>{language.t("session.summary.noBranch")}</span>
<Show when={props.baseBranch}>
{(base) => (
<>
@@ -305,17 +288,9 @@ export function SessionSummaryPanel(props: {
)}
</Show>
</button>
<div
class="grid transition-[grid-template-rows] duration-150 ease-out motion-reduce:transition-none"
classList={{
"grid-rows-[1fr]": props.backgroundTasks.length > 0,
"grid-rows-[0fr]": props.backgroundTasks.length === 0,
}}
>
<div class="min-h-0 overflow-hidden">
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
</div>
</div>
<Show when={props.backgroundTasks.length > 0}>
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
</Show>
</div>
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
<WorkspaceMoveAction
@@ -622,38 +597,44 @@ function MessageTimelineView(
<VirtualizedTimeline
workspaceSession={workspaceSession}
bottomSpacer={
<Show when={showWorking() || backgroundHintPresence.present()}>
<div
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-[1000px] md:mx-auto": props.centered,
}}
>
<>
<Show when={showWorking()}>
<div
class={`flex h-9 items-center gap-2 pt-3 text-[13px] font-[530] leading-text-compact ${turnPadding()}`}
data-component="session-working"
role="status"
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-[1000px] md:mx-auto": props.centered,
}}
>
<Show when={showWorking()}>
<div data-component="session-working" role="status">
<TextShimmer text={language.t("session.timeline.working")} active />
</div>
</Show>
<Show when={backgroundHintPresence.present()}>
<div
ref={setBackgroundHintRef}
data-component="session-background-hint-row"
class="duration-150 motion-reduce:animate-none"
classList={{
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
"animate-out fade-out fill-mode-forwards":
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
}}
>
<BackgroundMoveHint onMove={props.background.move} />
</div>
</Show>
<div class={`flex h-9 items-start pt-3 text-[13px] font-[530] leading-text-compact ${turnPadding()}`}>
<TextShimmer text={language.t("session.timeline.working")} active />
</div>
</div>
</div>
</Show>
</Show>
<Show when={backgroundHintPresence.present()}>
<div
data-component="session-background-hint-row"
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-[1000px] md:mx-auto": props.centered,
}}
>
<div
ref={setBackgroundHintRef}
class="duration-150 motion-reduce:animate-none"
classList={{
[`flex items-start ${showWorking() ? "h-6" : "h-9 pt-3"} ${turnPadding()}`]: true,
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
"animate-out fade-out fill-mode-forwards":
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
}}
>
<BackgroundMoveHint />
</div>
</div>
</Show>
</>
}
deferred={(row) => {
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
@@ -757,7 +738,7 @@ function MessageTimelineView(
/>
</Show>
</Show>
<Show when={!parentID() && sessionID()} keyed>
<Show when={sessionID()} keyed>
{(id) => (
<Menu
gutter={6}
@@ -70,13 +70,12 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
const project = await serverCtx().sdk.api.project.update({
await serverCtx().sdk.api.project.update({
projectID: props.project.id,
name,
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
commands: { start },
})
serverCtx().sync.project.update(project)
dialog.close()
return
}
@@ -122,10 +122,6 @@
outline: none;
}
[data-color-scheme="dark"] .command-palette-row:is([data-active], :focus-visible) {
background: var(--v2-alpha-light-10);
}
.command-palette-row-main {
display: flex;
min-width: 0;
+6 -12
View File
@@ -434,7 +434,7 @@ export function Titlebar(props: {
}}
>
<Show when={!mobile() && (!props.verticalTabs || windows())}>
<ChannelIndicator debugTools={props.debugTools} />
<ChannelIndicator debugTools={props.debugTools} height={windows() ? minHeight() : undefined} />
</Show>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
@@ -753,23 +753,18 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
)
}
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void }; height?: string }) {
const platform = usePlatform()
const windows = () => platform.platform === "desktop" && platform.os === "windows"
const classes = () => ({
"px-2 rounded-sm": windows(),
"inline-flex h-4 shrink-0 items-center leading-4 px-1.5 rounded-full": !windows(),
})
const style = () => ({
"font-size": windows() ? undefined : platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
height: props.height,
"font-size": platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
})
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
if (channel === "dev" && props.debugTools) {
return (
<button
type="button"
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono cursor-pointer [app-region:no-drag]"
classList={classes()}
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono cursor-pointer [app-region:no-drag]"
style={style()}
onClick={props.debugTools.toggle}
aria-label="Toggle debug tools"
@@ -785,8 +780,7 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
<Show when={label}>
{(value) => (
<div
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono"
classList={classes()}
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono"
style={style()}
>
{value()}
+1
View File
@@ -122,6 +122,7 @@
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
+120 -46
View File
@@ -4,7 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, FiberMap, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import {
AgentsDirectory,
ClaudeDirectory,
@@ -23,8 +23,6 @@ import { Location } from "./location.js"
import { AbsolutePath } from "./schema.js"
import { ConfigVariable } from "./config/variable.js"
import { ConfigNormalize } from "./config/normalize.js"
import { ConfigDiscovery } from "./config/discovery.js"
import { ConfigWatch } from "./config/watch.js"
import { WellKnown } from "./wellknown.js"
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
@@ -85,12 +83,15 @@ export const layer = (options?: Options) =>
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const wellknown = yield* WellKnown.Service
const names = ["opencode.json", "opencode.jsonc"]
const reloadLock = Semaphore.makeUnsafe(1)
const fileTargets = new Set<AbsolutePath>()
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
@@ -176,23 +177,90 @@ export const layer = (options?: Options) =>
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
return [
...(yield* Effect.forEach(ConfigDiscovery.names, (file) => loadFile(path.join(directory, file))).pipe(
...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)),
new Directory({ type: "directory", path: directory }),
]
})
const load = Effect.fn("Config.load")(function* (sources: ConfigDiscovery.Sources) {
const claude = yield* Effect.filter(sources.claude, (path) => fs.isDir(path))
const agents = yield* Effect.filter(sources.agents, (path) => fs.isDir(path))
const direct = yield* Effect.forEach(sources.direct, (filepath) => loadFile(filepath)).pipe(
const discover = Effect.fn("Config.discover")(function* () {
const globalDirectory = AbsolutePath.make(global.config)
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
// Global roots and the walk are compared by canonical path: the same
// directory reached under two spellings (a symlinked checkout, macOS
// /var vs /private/var, OPENCODE_CONFIG_DIR inside the project) must
// classify identically or it enters discovery twice.
const globalRoots = yield* Effect.forEach(
[globalDirectory, globalClaudeDirectory, globalAgentsDirectory],
(item) => fs.resolve(item),
)
const locationIsGlobal = (yield* fs.resolve(location.directory)) === globalRoots[0]
const discovered =
locationIsGlobal || options?.project === false
? []
: yield* fs
.up({
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
start: location.directory,
})
.pipe(
Effect.flatMap((items) =>
Effect.forEach(items, (item) =>
fs.resolve(item).pipe(Effect.map((resolved) => ({ item, resolved }))),
),
),
Effect.orDie,
)
const globalEnabled = options?.global !== false
// A walked path that resolves into a global root is global config
// however the walk reached it (home above the project, or a location
// beneath the global config dir), so global: false excludes it
// uniformly — classified once here, not per consumer below. With
// global enabled, the roots themselves and the global config files are
// already loaded below, so the walk must not add them a second time.
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
const visible = discovered
.filter(({ resolved }) =>
globalEnabled
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
)
.map(({ item }) => item)
// We load certain files from a few other folders in the ecosystem
const claude = [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
]),
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
const agents = [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
]),
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
const projectDirectories = visible
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory))
const directPaths = visible
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
.toReversed()
fileTargets.clear()
directPaths.forEach((filepath) => fileTargets.add(AbsolutePath.make(filepath)))
const direct = yield* Effect.forEach(directPaths, (filepath) => loadFile(filepath)).pipe(
Effect.orDie,
Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)),
)
const explicit = sources.explicit
? yield* loadFile(sources.explicit).pipe(
const file = options?.file
if (file) fileTargets.add(AbsolutePath.make(path.resolve(file)))
const explicit = file
? yield* loadFile(path.resolve(file)).pipe(
Effect.map((config) => (config ? [config] : [])),
Effect.orDie,
)
@@ -213,18 +281,15 @@ export const layer = (options?: Options) =>
// Global entries sit below explicit and direct files; project
// directories rank above them.
const globalSupplementary = sources.global ? yield* loadDirectory(sources.global).pipe(Effect.orDie) : []
const projectSupplementary = yield* Effect.forEach(
sources.project.filter((root) => root.present),
(root) => loadDirectory(root.path),
).pipe(
const globalSupplementary = globalEnabled ? yield* loadDirectory(globalDirectory).pipe(Effect.orDie) : []
const projectSupplementary = yield* Effect.forEach(projectDirectories, loadDirectory).pipe(
Effect.orDie,
Effect.map((entries) => entries.flat()),
)
return [
...(yield* loadWellknown().pipe(Effect.orDie)),
...claude.map((path) => new ClaudeDirectory({ type: "claude", path })),
...agents.map((path) => new AgentsDirectory({ type: "agents", path })),
...claude,
...agents,
...globalSupplementary,
...explicit,
...direct,
@@ -233,35 +298,44 @@ export const layer = (options?: Options) =>
]
})
const initial = yield* ConfigDiscovery.discover(options)
let configs = yield* load(initial)
const initial = yield* discover()
let configs = initial
const updates = yield* PubSub.unbounded<Watcher.Update>()
const reloads = yield* PubSub.sliding<void>(1)
// Readiness rescans recover writes made before a watch attached.
const requestReload = PubSub.publish(reloads, undefined).pipe(Effect.asVoid)
const watched = yield* FiberMap.make<string>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (sources: ConfigDiscovery.Sources) {
const plan = ConfigWatch.plan(sources)
for (const key of Array.from(watched, ([key]) => key)) {
if (!plan.has(key)) yield* FiberMap.remove(watched, key)
}
for (const [key, target] of plan) {
yield* watcher
.subscribe(target, requestReload)
.pipe(
Effect.flatMap(
Stream.runForEach((update) => PubSub.publish(updates, update).pipe(Effect.andThen(requestReload))),
),
FiberMap.run(watched, key, { onlyIfMissing: true, startImmediately: true }),
)
// Vendored trees inside config roots (a plugin's node_modules, a nested
// .git) produce event blizzards that can never change discovery output.
const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
// Watch-once: roots leave discovery only by deletion, so a stale watch is
// inert, bounded, and dies with this layer — and keeping a deleted root's
// watch alive is exactly what makes its recreation observable.
const watched = new Set<string>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const files = [
...entries.flatMap((entry) => (entry.type === "document" && entry.path ? [entry.path] : [])),
...fileTargets,
]
const targets = [
...directories.map((path) => ({ path, type: "directory" as const, ignore })),
...files
.filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
]
for (const target of targets) {
const key = JSON.stringify(target)
if (watched.has(key)) continue
watched.add(key)
const stream = yield* watcher.subscribe(target)
yield* stream.pipe(
Stream.runForEach((update) => PubSub.publish(updates, update)),
Effect.forkScoped({ startImmediately: true }),
)
}
})
const reload = Effect.fn("Config.reload")(
function* () {
const sources = yield* ConfigDiscovery.discover(options)
const next = yield* load(sources)
yield* reconcile(sources)
const next = yield* discover()
yield* reconcile(next)
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* bus.publish(Event.Updated, {})
@@ -269,12 +343,12 @@ export const layer = (options?: Options) =>
(effect) => reloadLock.withPermit(effect),
)
// Subscribe eagerly so synchronous watch readiness isn't dropped.
const pendingReloads = yield* PubSub.subscribe(reloads)
yield* Stream.fromSubscription(pendingReloads).pipe(
yield* Stream.fromPubSub(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { cause }))),
Stream.runForEach((update) =>
reload().pipe(
Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })),
),
),
Effect.forkScoped({ startImmediately: true }),
)
@@ -315,7 +389,7 @@ export const layer = (options?: Options) =>
Effect.forever,
Effect.forkScoped({ startImmediately: true }),
)
yield* reloadLock.withPermit(reconcile(initial))
yield* reconcile(initial)
return Service.of({
entries: Effect.fnUntraced(function* () {
-84
View File
@@ -1,84 +0,0 @@
export * as ConfigDiscovery from "./discovery.js"
import path from "path"
import { Effect } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../location.js"
import { AbsolutePath } from "../schema.js"
import type { Options } from "../config.js"
export const names = ["opencode.json", "opencode.jsonc"]
/** Eligible sources in priority order, including paths that may appear later. */
export interface Sources {
readonly global?: AbsolutePath
readonly explicit?: AbsolutePath
readonly direct: readonly AbsolutePath[]
readonly project: readonly { readonly path: AbsolutePath; readonly present: boolean }[]
readonly claude: readonly AbsolutePath[]
readonly agents: readonly AbsolutePath[]
}
export const discover = Effect.fn("ConfigDiscovery.discover")(function* (options?: Options) {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const globalDirectory = AbsolutePath.make(global.config)
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
const globalRoots = yield* Effect.forEach([globalDirectory, globalClaudeDirectory, globalAgentsDirectory], (item) =>
fs.resolve(item),
)
const directories =
(yield* fs.resolve(location.directory)) === globalRoots[0] || options?.project === false
? []
: yield* fs.up({ targets: ["."], start: location.directory }).pipe(Effect.orDie)
const discovered = yield* Effect.forEach(directories, (directory) =>
Effect.gen(function* () {
// Resolve the parent too: missing children must honor symlinked global roots.
const parent = yield* fs.resolve(directory)
return yield* Effect.forEach([".claude", ".agents", ".opencode", ...names.toReversed()], (name) =>
fs
.resolve(path.join(parent, name))
.pipe(Effect.map((resolved) => ({ item: AbsolutePath.make(path.join(directory, name)), resolved }))),
)
}),
).pipe(
Effect.map((items) => items.flat()),
Effect.orDie,
)
const globalEnabled = options?.global !== false
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
// Global sources must not re-enter through the project walk.
const visible = discovered
.filter(({ resolved }) =>
globalEnabled
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
)
.map(({ item }) => item)
return {
global: globalEnabled ? globalDirectory : undefined,
explicit: options?.file ? AbsolutePath.make(path.resolve(options.file)) : undefined,
direct: visible.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))).toReversed(),
project: yield* Effect.forEach(
visible.filter((item) => path.basename(item) === ".opencode").toReversed(),
(directory) => fs.isDir(directory).pipe(Effect.map((present) => ({ path: directory, present }))),
),
claude: [
...new Set([
...(globalEnabled ? [globalClaudeDirectory] : []),
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
]),
],
agents: [
...new Set([
...(globalEnabled ? [globalAgentsDirectory] : []),
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
]),
],
} satisfies Sources
})
+1 -1
View File
@@ -33,7 +33,7 @@ export const Plugin = define({
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: "file" | "directory") {
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe({ path: target, type })
yield* FiberMap.run(
-39
View File
@@ -1,39 +0,0 @@
export * as ConfigWatch from "./watch.js"
import path from "path"
import { FSUtil } from "@opencode-ai/util/fs-util"
import type { Watcher } from "../filesystem/watcher.js"
import type { ConfigDiscovery } from "./discovery.js"
export function plan(sources: ConfigDiscovery.Sources) {
const directories = [
...(sources.global ? [sources.global] : []),
...sources.project.filter((root) => root.present).map((root) => root.path),
]
const files = [
...sources.direct,
...sources.project.map((root) => root.path),
...sources.claude,
...sources.agents,
...(sources.explicit ? [sources.explicit] : []),
]
// Keep a parent watch for each root so deletion/recreation is observable.
const parents = Map.groupBy(
files.filter((file) => !directories.some((directory) => file !== directory && FSUtil.contains(directory, file))),
(file) => path.dirname(file),
)
return new Map(
[
...directories.map((path) => ({
path,
type: "directory" as const,
ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"],
})),
...Array.from(parents, ([parent, files]) => ({
path: parent,
type: "entries" as const,
names: [...new Set(files.map((file) => path.basename(file)))].toSorted(),
})),
].map((target) => [JSON.stringify(target), target satisfies Watcher.WatchInput]),
)
}
+57 -55
View File
@@ -34,7 +34,6 @@ export type Update = ParcelWatcher.Event
export type WatchInput =
| { readonly path: string; readonly type: "file" }
| { readonly path: string; readonly type: "entries"; readonly names: readonly string[] }
| { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] }
export type Subscription = {
@@ -43,26 +42,25 @@ export type Subscription = {
readonly backend?: string
}
type Target = {
readonly target: string
readonly ignore: readonly string[]
} & (
| { readonly type: "entries"; readonly names: readonly string[] }
| { readonly type: "file" | "directory"; readonly names?: readonly string[] }
)
export interface NativeInterface {
readonly subscribe: (
input: Target & { readonly publish: (update: Update) => void },
) => Effect.Effect<Subscription | undefined>
/** Starts one OS-level watch, reporting events through `publish` until unsubscribed. */
readonly subscribe: (input: {
readonly type: WatchInput["type"]
readonly target: string
readonly ignore: readonly string[]
readonly publish: (update: Update) => void
}) => Effect.Effect<Subscription | undefined>
}
/** Uses fs.watch for immediate entries and Parcel for recursive directories. */
/**
* The OS-level watch implementation behind the Watcher service. The default
* layer uses `node:fs.watch` for files and `@parcel/watcher` for directories;
* tests provide implementations they can control.
*/
export class Native extends Context.Service<Native, NativeInterface>()("@opencode/Watcher/Native") {}
export interface Interface {
/** onReady runs after native acquisition and listener registration, when the stream is consumed. */
readonly subscribe: (input: WatchInput, onReady?: Effect.Effect<void>) => Effect.Effect<Stream.Stream<Update>>
readonly subscribe: (input: WatchInput) => Effect.Effect<Stream.Stream<Update>>
}
export const Options = Schema.Struct({
@@ -91,13 +89,16 @@ export const layer = (options?: Options) =>
const native = yield* Native
// Keys compare structurally (effect Equal), so equivalent watches share one entry.
type Key = { readonly type: WatchInput["type"]; readonly target: string; readonly ignore: readonly string[] }
const watchers = yield* RcMap.make({
lookup: (key: Target) =>
lookup: (key: Key) =>
Effect.gen(function* () {
const pubsub = yield* Effect.acquireRelease(PubSub.unbounded<Update>(), (pubsub) => PubSub.shutdown(pubsub))
const subscription = yield* Effect.acquireRelease(
native.subscribe({
...key,
type: key.type,
target: key.target,
ignore: key.ignore,
publish: (update) => PubSub.publishUnsafe(pubsub, update),
}),
(subscription) =>
@@ -126,31 +127,34 @@ export const layer = (options?: Options) =>
}),
})
const subscribe = Effect.fnUntraced(function* (input: WatchInput, onReady: Effect.Effect<void> = Effect.void) {
const subscribe = (input: WatchInput) => {
const target = path.resolve(input.path)
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
const names = [...new Set(input.type === "entries" ? input.names : [])].toSorted()
yield* Effect.logInfo("watcher subscribe", {
path: target,
type: input.type,
ignores: ignore.length,
return Effect.gen(function* () {
yield* Effect.logInfo("watcher subscribe", {
path: target,
type: input.type,
ignores: ignore.length,
})
return Stream.unwrap(
Effect.gen(function* () {
const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore })
return Stream.fromPubSub(pubsub)
}),
)
})
return Stream.unwrap(
Effect.gen(function* () {
const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore, names })
const subscription = yield* PubSub.subscribe(pubsub)
if (yield* PubSub.isShutdown(pubsub)) return Stream.empty
yield* onReady
return Stream.fromSubscription(subscription)
}),
)
})
}
return Service.of({ subscribe })
}),
)
/** Real subscription lifecycle with in-memory, path-filtered event delivery. */
/**
* Watcher for tests: the real lifecycle over an in-memory Native that records
* acquired watches and routes emitted updates the way the OS watches would: a
* file watch receives updates for its own path, a directory watch receives
* updates for paths inside it that no ignore entry covers.
*/
export const testLayer = Layer.effectContext(
Effect.gen(function* () {
const subscriptions: WatchInput[] = []
@@ -161,22 +165,21 @@ export const testLayer = Layer.effectContext(
subscriptions.push(
input.type === "file"
? { path: input.target, type: "file" }
: input.type === "entries"
? { path: input.target, type: "entries", names: input.names }
: input.ignore.length > 0
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
: input.ignore.length > 0
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
)
// Ignore entries resolve against the target like the parcel wrapper's
// literal paths. Glob entries resolve to paths nothing lives under, so
// they are inert here rather than compiled the way parcel compiles them.
const ignored = input.ignore.map((entry) => path.resolve(input.target, entry))
active.set(input.publish, (target) => {
if (input.type === "file") return target === input.target
if (input.type === "entries")
return path.dirname(target) === input.target && input.names.includes(path.basename(target))
return FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target))
})
active.set(
input.publish,
input.type === "file"
? (target) => target === input.target
: (target) =>
FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target)),
)
return {
unsubscribe: () => {
active.delete(input.publish)
@@ -205,19 +208,18 @@ export const nativeLayer = Layer.succeed(
Native,
Native.of({
subscribe: (input) => {
if (input.type === "file" || input.type === "entries") {
if (input.type === "file") {
return Effect.sync(() => {
const directory = input.type === "file" ? path.dirname(input.target) : input.target
const names = new Set(input.type === "file" ? [path.basename(input.target)] : input.names)
const directory = path.dirname(input.target)
const subscription = watch(directory, { recursive: false }, (_event, file) => {
if (file && !names.has(file)) return
for (const name of file ? [file] : names) {
input.publish({ path: path.join(directory, name), type: "update" })
}
if (file && path.resolve(directory, file.toString()) !== input.target) return
input.publish({ path: input.target, type: "update" } satisfies Update)
})
subscription.on("error", (error: unknown) =>
Effect.runFork(Effect.logError("watcher callback failed", { path: directory, error })),
)
if ("on" in subscription && typeof subscription.on === "function") {
subscription.on("error", (error: unknown) =>
Effect.runFork(Effect.logError("watcher callback failed", { path: input.target, error })),
)
}
return { unsubscribe: () => Promise.resolve(subscription.close()), backend: "node" }
})
}
+2
View File
@@ -77,6 +77,7 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
import { WellKnown } from "../wellknown.js"
import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode-ai/plugin-browser"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -188,6 +189,7 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
BrowserPlugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
+2 -3
View File
@@ -4,7 +4,7 @@ import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { Tool } from "@opencode-ai/schema/tool"
import { Skill } from "@opencode-ai/schema/skill"
import { eq } from "drizzle-orm"
import { Clock, Context, DateTime, Effect, Layer, Schema } from "effect"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { map } from "effect/Array"
import path from "path"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -73,7 +73,6 @@ const layer = Layer.effect(
if (input.data.info.parentID) yield* sessions.get(input.data.info.parentID)
const project = yield* projects.resolve(input.location.directory)
yield* upsertProject(db, project).pipe(Effect.orDie)
const importedAt = yield* Clock.currentTimeMillis
const messages = input.data.messages.filter(isSettled).map((message, index) => {
const encoded = encodeMessage(message)
const { id: _, type, ...data } = encoded
@@ -122,7 +121,7 @@ const layer = Layer.effect(
tokens_cache_read: input.data.info.tokens.cache.read,
tokens_cache_write: input.data.info.tokens.cache.write,
time_created: DateTime.toEpochMillis(input.data.info.time.created),
time_updated: importedAt,
time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
time_idle: input.data.info.time.idle ? DateTime.toEpochMillis(input.data.info.time.idle) : null,
time_viewed:
input.data.info.time.idle && input.data.info.time.viewed
+9 -60
View File
@@ -105,10 +105,6 @@ describe("Config", () => {
Effect.gen(function* () {
const config = yield* Config.Service
expect(ambient(yield* config.entries())).toEqual([])
const watcher = yield* Watcher.Test
expect(
(yield* watcher.subscriptions()).filter((watch) => watch.type === "entries" && watch.path === home),
).toEqual([])
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, undefined, undefined, { global: false }),
@@ -169,16 +165,7 @@ describe("Config", () => {
const entries = yield* config.entries()
expect(entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))).toEqual([global])
expect(entries.flatMap((entry) => (entry.type === "document" ? [entry.info.shell] : []))).toEqual(["global"])
expect(
(yield* watcher.subscriptions())
.filter((subscription) => subscription.type === "directory")
.map((subscription) => subscription.path),
).toEqual([global])
expect(
(yield* watcher.subscriptions()).filter((subscription) =>
subscription.path.includes(`${path.sep}.opencode${path.sep}`),
),
).toEqual([])
expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global])
})
return Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
@@ -243,8 +230,6 @@ describe("Config", () => {
Effect.gen(function* () {
const config = yield* Config.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
const watcher = yield* Watcher.Test
expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global])
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
@@ -258,19 +243,17 @@ describe("Config", () => {
),
)
it.live("reloads file substitutions when their source changes", () =>
it.live("reloads external config and publishes directory updates", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const file = path.join(global, "opencode.json")
const source = path.join(global, "shell.txt")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(source, "first")
await fs.writeFile(file, JSON.stringify({ shell: "{file:shell.txt}" }))
await fs.writeFile(file, JSON.stringify({ shell: "first" }))
})
return yield* Effect.gen(function* () {
const config = yield* Config.Service
@@ -281,8 +264,9 @@ describe("Config", () => {
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.sleep("10 millis")
yield* Effect.promise(() => fs.writeFile(source, "second"))
yield* watcher.emit({ type: "update", path: source })
yield* watcher.emit({ type: "update", path: path.join(global, "commands", "review.md") })
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
yield* watcher.emit({ type: "update", path: file })
expect(yield* Fiber.join(changed)).toHaveLength(1)
expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
@@ -292,35 +276,6 @@ describe("Config", () => {
),
)
it.live("excludes missing files under symlinked global roots when global is disabled", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const link = path.join(tmp.path, "link")
const project = path.join(link, "plugins", "demo")
return Effect.promise(async () => {
await fs.mkdir(path.join(global, "plugins", "demo"), { recursive: true })
await fs.symlink(global, link, process.platform === "win32" ? "junction" : undefined)
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const watcher = yield* Watcher.Test
const subscriptions = yield* watcher.subscriptions()
expect(subscriptions.length).toBeGreaterThan(0)
expect(
subscriptions.filter((item) => inFixture(global, item.path) || inFixture(link, item.path)),
).toEqual([])
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, undefined, undefined, { global: false }),
),
),
),
)
}),
),
)
it.live("exposes filesystem updates under config roots through changes", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
@@ -916,7 +871,7 @@ describe("Config", () => {
),
)
it.live("does not recursively watch ecosystem config roots", () =>
it.live("does not watch ecosystem config roots", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
@@ -937,11 +892,6 @@ describe("Config", () => {
path: AbsolutePath.make(path.join(tmp.path, "global")),
ignore: ["**/{node_modules,.git}/**", ".git", "node_modules"],
},
{
type: "entries",
path: tmp.path,
names: [".agents", ".claude", ".opencode", "opencode.json", "opencode.jsonc"],
},
])
}).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, Watcher.testLayer)))
}),
@@ -1497,9 +1447,8 @@ describe("Config", () => {
expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
expect(yield* watcher.subscriptions()).toContainEqual({
path: tmp.path,
type: "entries",
names: [".agents", ".claude", ".opencode", "opencode.json", "opencode.jsonc"],
path: path.join(tmp.path, "opencode.jsonc"),
type: "file",
})
}).pipe(Effect.provide(testLayer(tmp.path)))
}),

Some files were not shown because too many files have changed in this diff Show More