void move(workspace)}>
diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx
index d3bc5237775..1e0443d26e6 100644
--- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx
+++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx
@@ -1,7 +1,7 @@
import { Component, createMemo, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
-import { Icon } from "@opencode-ai/ui/icon"
+import { Icon } from "@opencode-ai/ui/v2/icon"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { SettingsGeneralV2 } from "./general"
@@ -70,7 +70,7 @@ export const DialogSettings: Component<{
{language.t("settings.section.server")}
-
+
{language.t("settings.tab.workspaces")}
diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx
index d6f2f260d3c..1a52a394214 100644
--- a/packages/app/src/context/server-sdk.tsx
+++ b/packages/app/src/context/server-sdk.tsx
@@ -291,57 +291,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
let streamErrorLogged = false
- let worktreeStreamErrorLogged = false
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
let attempt: AbortController | undefined
- let worktreeAttempt: AbortController | undefined
let run: Promise | undefined
let started = false
let generation = 0
- const consumeWorktreeEvents = async (active: number) => {
- // Current worktree lifecycle events are still emitted only on the global compatibility stream.
- while (!abort.signal.aborted && started && generation === active) {
- const controller = new AbortController()
- worktreeAttempt = controller
- const onAbort = () => controller.abort()
- abort.signal.addEventListener("abort", onAbort)
- try {
- const events = (await eventSdk.global.event({ signal: controller.signal })).stream
- let yielded = Date.now()
- for await (const event of events) {
- const queued = adaptWorktreeCompatibilityEvent({
- directory: event.directory,
- payload: event.payload as Event,
- })
- if (queued) {
- worktreeStreamErrorLogged = false
- if (enqueueServerEvent(queue, queued)) schedule()
- }
-
- if (Date.now() - yielded < STREAM_YIELD_MS) continue
- yielded = Date.now()
- await wait(0)
- }
- } catch (error) {
- if (!isStreamClosed(error, controller.signal) && !worktreeStreamErrorLogged) {
- worktreeStreamErrorLogged = true
- console.error("[global-sdk] worktree event stream failed", {
- url: server.http.url,
- fetch: eventFetch ? "platform" : "webview",
- error,
- })
- }
- } finally {
- abort.signal.removeEventListener("abort", onAbort)
- if (worktreeAttempt === controller) worktreeAttempt = undefined
- }
-
- if (abort.signal.aborted || !started || generation !== active) return
- await wait(RECONNECT_DELAY_MS)
- }
- }
-
const start = () => {
if (started) return run
started = true
@@ -350,7 +305,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
const current = (async () => {
if (previous) await previous
const kind = await protocol
- if (kind === "v2") void consumeWorktreeEvents(active)
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started && generation === active) {
attempt = new AbortController()
@@ -406,7 +360,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
started = false
generation++
attempt?.abort()
- worktreeAttempt?.abort()
}
onMount(() => {
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts
index b7daf06cedd..30a767c8390 100644
--- a/packages/app/src/i18n/ar.ts
+++ b/packages/app/src/i18n/ar.ts
@@ -1042,10 +1042,18 @@ export const dict = {
"settings.tab.workspaces": "مساحات العمل",
"settings.workspaces.filter.all": "جميع المشاريع",
"settings.workspaces.empty": "لا توجد مساحات عمل",
+ "settings.workspaces.count.zero": "لا توجد مساحات عمل ({{count}})",
"settings.workspaces.count.one": "{{count}} مساحة عمل",
- "settings.workspaces.count.other": "{{count}} مساحات عمل",
+ "settings.workspaces.count.two": "مساحتا عمل ({{count}})",
+ "settings.workspaces.count.few": "{{count}} مساحات عمل",
+ "settings.workspaces.count.many": "{{count}} مساحة عمل",
+ "settings.workspaces.count.other": "{{count}} مساحة عمل",
+ "settings.workspaces.sessions.zero": "لا توجد جلسات ({{count}}) في {{project}}",
"settings.workspaces.sessions.one": "{{count}} جلسة في {{project}}",
- "settings.workspaces.sessions.other": "{{count}} جلسات في {{project}}",
+ "settings.workspaces.sessions.two": "جلستان ({{count}}) في {{project}}",
+ "settings.workspaces.sessions.few": "{{count}} جلسات في {{project}}",
+ "settings.workspaces.sessions.many": "{{count}} جلسة في {{project}}",
+ "settings.workspaces.sessions.other": "{{count}} جلسة في {{project}}",
"settings.workspaces.lastActiveSession": "آخر جلسة نشطة",
"settings.workspaces.deleteAll": "حذف جميع مساحات العمل",
"settings.workspaces.deleteAll.confirm": "حذف جميع مساحات العمل البالغ عددها {{count}}؟",
@@ -1061,11 +1069,18 @@ export const dict = {
"settings.workspaces.default.local": "دليل محلي",
"settings.workspaces.default.new": "مساحة عمل جديدة",
"workspace.move.title": "نقل إلى مساحة العمل",
+ "workspace.move.menu.title": "نقل الجلسة إلى",
"workspace.move.failed": "تعذّر نقل الجلسة",
"workspace.lifecycle.creating": "جارٍ إنشاء مساحة العمل",
"workspace.lifecycle.created": "تم إنشاء مساحة العمل",
+ "workspace.lifecycle.starting": "جارٍ بدء الجلسة",
+ "workspace.onboarding.title": "عزل الجلسات باستخدام مساحات العمل",
+ "workspace.onboarding.description": "تحصل كل جلسة على نسختها الخاصة، حتى لا يتداخل أي شيء مع مستودعك المحلي",
"workspace.lifecycle.moving": "جارٍ النقل إلى مساحة العمل",
"workspace.lifecycle.set": "تم تعيين مساحة العمل",
+ "session.summary.title": "تفاصيل الجلسة",
+ "session.summary.noBranch": "لا يوجد فرع",
+ "session.summary.basedOn": "بناءً على {{branch}}",
"workspace.type.local": "محلي",
"workspace.type.sandbox": "بيئة معزولة",
"workspace.create.failed.title": "فشل إنشاء مساحة العمل",
@@ -1188,4 +1203,13 @@ export const dict = {
"error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
"error.childStore.storeCreateFailed": "فشل إنشاء المخزن",
"terminal.connectionLost.abnormalClose": "تم إغلاق WebSocket بشكل غير طبيعي: {{code}}",
+
+ "command.session.export": "تصدير الجلسة",
+ "command.session.export.description": "تصدير بيانات الجلسة",
+ "context.export.session": "تصدير الجلسة",
+ "toast.session.export.success.title": "تم تصدير الجلسة",
+ "toast.session.export.success.description": "تم حفظ {{filename}} بنجاح",
+ "toast.session.export.failed.title": "فشل تصدير الجلسة",
+ "toast.session.export.failed.description": "حدث خطأ أثناء تصدير الجلسة",
+ "common.export": "تصدير",
}
diff --git a/packages/app/src/i18n/az.ts b/packages/app/src/i18n/az.ts
index df3506c3d55..11f79695a85 100644
--- a/packages/app/src/i18n/az.ts
+++ b/packages/app/src/i18n/az.ts
@@ -1152,4 +1152,50 @@ export const dict = {
"workspace.reset.archived.one": "1 sessiya arxivlənəcək.",
"workspace.reset.archived.many": "{{count}} sessiya arxivlənəcək.",
"workspace.reset.note": "Bu iş sahəsini standart branch ilə uyğunlaşdırmaq üçün sıfırlayacaq.",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "common.viewAll": "View all",
+ "session.new.workspace.local.tooltip": "Use current checkout",
+ "session.new.workspace.new.tooltip": "Create isolated checkout",
+ "session.new.workspace.fromBranch": "from {{branch}}",
+ "session.new.workspace.trigger.tooltip": "Select where to run session",
+ "session.new.workspace.search.placeholder": "Search workspaces",
+ "settings.tab.workspaces": "Workspaces",
+ "settings.workspaces.filter.all": "All projects",
+ "settings.workspaces.empty": "No workspaces",
+ "settings.workspaces.count.one": "{{count}} workspace",
+ "settings.workspaces.count.other": "{{count}} workspaces",
+ "settings.workspaces.sessions.one": "{{count}} session in {{project}}",
+ "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
+ "settings.workspaces.lastActiveSession": "Last active session",
+ "settings.workspaces.deleteAll": "Delete all workspaces",
+ "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
+ "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
+ "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
+ "settings.workspaces.default.title": "Default environment",
+ "settings.workspaces.default.description": "Choose where new sessions start",
+ "settings.workspaces.default.lastUsed": "Last used per project",
+ "settings.workspaces.default.local": "Local directory",
+ "settings.workspaces.default.new": "New workspace",
+ "workspace.move.title": "Move to workspace",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.move.failed": "Failed to move session",
+ "workspace.lifecycle.creating": "Creating workspace",
+ "workspace.lifecycle.created": "Workspace created",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "workspace.lifecycle.moving": "Moving to workspace",
+ "workspace.lifecycle.set": "Workspace set",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
}
diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts
index c39cbe5aa30..dea8badca4a 100644
--- a/packages/app/src/i18n/br.ts
+++ b/packages/app/src/i18n/br.ts
@@ -1051,8 +1051,10 @@ export const dict = {
"settings.workspaces.filter.all": "Todos os projetos",
"settings.workspaces.empty": "Nenhum espaço de trabalho",
"settings.workspaces.count.one": "{{count}} espaço de trabalho",
+ "settings.workspaces.count.many": "{{count}} espaços de trabalho",
"settings.workspaces.count.other": "{{count}} espaços de trabalho",
"settings.workspaces.sessions.one": "{{count}} sessão em {{project}}",
+ "settings.workspaces.sessions.many": "{{count}} sessões em {{project}}",
"settings.workspaces.sessions.other": "{{count}} sessões em {{project}}",
"settings.workspaces.lastActiveSession": "Última sessão ativa",
"settings.workspaces.deleteAll": "Excluir todos os espaços de trabalho",
@@ -1070,11 +1072,18 @@ export const dict = {
"settings.workspaces.default.local": "Diretório local",
"settings.workspaces.default.new": "Novo espaço de trabalho",
"workspace.move.title": "Mover para o espaço de trabalho",
+ "workspace.move.menu.title": "Mover sessão para",
"workspace.move.failed": "Não foi possível mover a sessão",
"workspace.lifecycle.creating": "Criando espaço de trabalho",
"workspace.lifecycle.created": "Espaço de trabalho criado",
+ "workspace.lifecycle.starting": "Iniciando sessão",
+ "workspace.onboarding.title": "Isole sessões com espaços de trabalho",
+ "workspace.onboarding.description": "Cada um recebe seu próprio checkout, para não interferir com seu repositório local",
"workspace.lifecycle.moving": "Movendo para o espaço de trabalho",
"workspace.lifecycle.set": "Espaço de trabalho definido",
+ "session.summary.title": "Detalhes da sessão",
+ "session.summary.noBranch": "Nenhum branch",
+ "session.summary.basedOn": "Com base em {{branch}}",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Falha ao criar espaço de trabalho",
@@ -1199,4 +1208,13 @@ export const dict = {
"error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
"error.childStore.storeCreateFailed": "Falha ao criar armazenamento",
"terminal.connectionLost.abnormalClose": "WebSocket fechado anormalmente: {{code}}",
+
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
}
diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts
index e7682a91e7b..0c45b7c9315 100644
--- a/packages/app/src/i18n/bs.ts
+++ b/packages/app/src/i18n/bs.ts
@@ -1127,8 +1127,10 @@ export const dict = {
"settings.workspaces.filter.all": "Svi projekti",
"settings.workspaces.empty": "Nema radnih prostora",
"settings.workspaces.count.one": "{{count}} radni prostor",
+ "settings.workspaces.count.few": "{{count}} radna prostora",
"settings.workspaces.count.other": "{{count}} radnih prostora",
"settings.workspaces.sessions.one": "{{count}} sesija u projektu {{project}}",
+ "settings.workspaces.sessions.few": "{{count}} sesije u projektu {{project}}",
"settings.workspaces.sessions.other": "{{count}} sesija u projektu {{project}}",
"settings.workspaces.lastActiveSession": "Posljednja aktivna sesija",
"settings.workspaces.deleteAll": "Izbriši sve radne prostore",
@@ -1145,11 +1147,18 @@ export const dict = {
"settings.workspaces.default.local": "Lokalni direktorij",
"settings.workspaces.default.new": "Novi radni prostor",
"workspace.move.title": "Premjesti u radni prostor",
+ "workspace.move.menu.title": "Premjesti sesiju u",
"workspace.move.failed": "Premještanje sesije nije uspjelo",
"workspace.lifecycle.creating": "Kreiranje radnog prostora",
"workspace.lifecycle.created": "Radni prostor je kreiran",
+ "workspace.lifecycle.starting": "Pokretanje sesije",
+ "workspace.onboarding.title": "Izolujte sesije pomoću radnih prostora",
+ "workspace.onboarding.description": "Svaki dobija vlastiti checkout, tako da ništa ne ometa vaš lokalni repozitorij",
"workspace.lifecycle.moving": "Premještanje u radni prostor",
"workspace.lifecycle.set": "Radni prostor je postavljen",
+ "session.summary.title": "Detalji sesije",
+ "session.summary.noBranch": "Nema grane",
+ "session.summary.basedOn": "Na osnovu {{branch}}",
"workspace.type.local": "lokalno",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Neuspješno kreiranje radnog prostora",
@@ -1274,4 +1283,12 @@ export const dict = {
"error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
"error.childStore.storeCreateFailed": "Nije uspjelo kreiranje skladišta",
"terminal.connectionLost.abnormalClose": "WebSocket zatvoren nenormalno: {{code}}",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
}
diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts
index bca2f56b3d3..28a8b690c6b 100644
--- a/packages/app/src/i18n/da.ts
+++ b/packages/app/src/i18n/da.ts
@@ -1265,4 +1265,19 @@ export const dict = {
"help.tabs.persistence": "Dine faner er stadig åbne, når du åbner appen igen.",
"help.tabs.worktrees":
"Det nye design understøtter endnu ikke Git Worktrees, men understøttelsen kommer snart. Hvis du foretrækker at fortsætte med det tidligere layout, kan du skifte mellem layoutene under Indstillinger. Bemærk, at det nye layout bliver permanent om nogle få uger.",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
}
diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts
index c77f7f6738e..c802abd1bcd 100644
--- a/packages/app/src/i18n/de.ts
+++ b/packages/app/src/i18n/de.ts
@@ -1203,4 +1203,19 @@ export const dict = {
"help.tabs.persistence": "Wenn Sie die App erneut öffnen, sind Ihre Tabs weiterhin geöffnet.",
"help.tabs.worktrees":
"Das neue Design unterstützt Git-Worktrees noch nicht. Diese Funktion folgt in Kürze. Wenn Sie das bisherige Layout vorerst weiterverwenden möchten, können Sie in den Einstellungen zwischen den Layouts wechseln. Beachten Sie jedoch, dass das neue Layout in einigen Wochen dauerhaft verwendet wird.",
-} satisfies Partial>
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
+}
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index 4c97144741c..f7aa6095fe0 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -1135,8 +1135,10 @@ export const dict = {
"settings.workspaces.filter.all": "Todos los proyectos",
"settings.workspaces.empty": "No hay espacios de trabajo",
"settings.workspaces.count.one": "{{count}} espacio de trabajo",
+ "settings.workspaces.count.many": "{{count}} espacios de trabajo",
"settings.workspaces.count.other": "{{count}} espacios de trabajo",
"settings.workspaces.sessions.one": "{{count}} sesión en {{project}}",
+ "settings.workspaces.sessions.many": "{{count}} sesiones en {{project}}",
"settings.workspaces.sessions.other": "{{count}} sesiones en {{project}}",
"settings.workspaces.lastActiveSession": "Última sesión activa",
"settings.workspaces.deleteAll": "Eliminar todos los espacios de trabajo",
@@ -1154,11 +1156,18 @@ export const dict = {
"settings.workspaces.default.local": "Directorio local",
"settings.workspaces.default.new": "Nuevo espacio de trabajo",
"workspace.move.title": "Mover al espacio de trabajo",
+ "workspace.move.menu.title": "Mover sesión a",
"workspace.move.failed": "No se pudo mover la sesión",
"workspace.lifecycle.creating": "Creando espacio de trabajo",
"workspace.lifecycle.created": "Espacio de trabajo creado",
+ "workspace.lifecycle.starting": "Iniciando sesión",
+ "workspace.onboarding.title": "Aísla sesiones con espacios de trabajo",
+ "workspace.onboarding.description": "Cada uno obtiene su propio checkout, para no interferir con tu repositorio local",
"workspace.lifecycle.moving": "Moviendo al espacio de trabajo",
"workspace.lifecycle.set": "Espacio de trabajo establecido",
+ "session.summary.title": "Detalles de la sesión",
+ "session.summary.noBranch": "Sin rama",
+ "session.summary.basedOn": "Basado en {{branch}}",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Fallo al crear espacio de trabajo",
@@ -1283,4 +1292,12 @@ export const dict = {
"error.childStore.persistedProjectIconCreateFailed": "Error al crear icono de proyecto persistente",
"error.childStore.storeCreateFailed": "Error al crear almacén",
"terminal.connectionLost.abnormalClose": "WebSocket cerrado anormalmente: {{code}}",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
}
diff --git a/packages/app/src/i18n/fi.ts b/packages/app/src/i18n/fi.ts
index 7955cda59a6..4ed775e6f99 100644
--- a/packages/app/src/i18n/fi.ts
+++ b/packages/app/src/i18n/fi.ts
@@ -1152,4 +1152,50 @@ export const dict = {
"help.tabs.persistence": "Kun avaat sovelluksen uudelleen, välilehtesi ovat yhä avoinna.",
"help.tabs.worktrees":
"Uusi ulkoasu ei vielä tue Git-työpuita, mutta tuki on tulossa pian. Jos haluat jatkaa aiemman ulkoasun käyttöä, voit vaihtaa ulkoasua asetuksissa. Huomaa kuitenkin, että uudesta ulkoasusta tulee pysyvä muutaman viikon kuluttua.",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "common.viewAll": "View all",
+ "session.new.workspace.local.tooltip": "Use current checkout",
+ "session.new.workspace.new.tooltip": "Create isolated checkout",
+ "session.new.workspace.fromBranch": "from {{branch}}",
+ "session.new.workspace.trigger.tooltip": "Select where to run session",
+ "session.new.workspace.search.placeholder": "Search workspaces",
+ "settings.tab.workspaces": "Workspaces",
+ "settings.workspaces.filter.all": "All projects",
+ "settings.workspaces.empty": "No workspaces",
+ "settings.workspaces.count.one": "{{count}} workspace",
+ "settings.workspaces.count.other": "{{count}} workspaces",
+ "settings.workspaces.sessions.one": "{{count}} session in {{project}}",
+ "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
+ "settings.workspaces.lastActiveSession": "Last active session",
+ "settings.workspaces.deleteAll": "Delete all workspaces",
+ "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
+ "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
+ "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
+ "settings.workspaces.default.title": "Default environment",
+ "settings.workspaces.default.description": "Choose where new sessions start",
+ "settings.workspaces.default.lastUsed": "Last used per project",
+ "settings.workspaces.default.local": "Local directory",
+ "settings.workspaces.default.new": "New workspace",
+ "workspace.move.title": "Move to workspace",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.move.failed": "Failed to move session",
+ "workspace.lifecycle.creating": "Creating workspace",
+ "workspace.lifecycle.created": "Workspace created",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "workspace.lifecycle.moving": "Moving to workspace",
+ "workspace.lifecycle.set": "Workspace set",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
}
diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts
index f4ceda65574..55db57fc41f 100644
--- a/packages/app/src/i18n/fr.ts
+++ b/packages/app/src/i18n/fr.ts
@@ -1061,8 +1061,10 @@ export const dict = {
"settings.workspaces.filter.all": "Tous les projets",
"settings.workspaces.empty": "Aucun espace de travail",
"settings.workspaces.count.one": "{{count}} espace de travail",
+ "settings.workspaces.count.many": "{{count}} espaces de travail",
"settings.workspaces.count.other": "{{count}} espaces de travail",
"settings.workspaces.sessions.one": "{{count}} session dans {{project}}",
+ "settings.workspaces.sessions.many": "{{count}} sessions dans {{project}}",
"settings.workspaces.sessions.other": "{{count}} sessions dans {{project}}",
"settings.workspaces.lastActiveSession": "Dernière session active",
"settings.workspaces.deleteAll": "Supprimer tous les espaces de travail",
@@ -1080,11 +1082,18 @@ export const dict = {
"settings.workspaces.default.local": "Dossier local",
"settings.workspaces.default.new": "Nouvel espace de travail",
"workspace.move.title": "Déplacer vers l’espace de travail",
+ "workspace.move.menu.title": "Déplacer la session vers",
"workspace.move.failed": "Impossible de déplacer la session",
"workspace.lifecycle.creating": "Création de l’espace de travail",
"workspace.lifecycle.created": "Espace de travail créé",
+ "workspace.lifecycle.starting": "Démarrage de la session",
+ "workspace.onboarding.title": "Isolez vos sessions avec les espaces de travail",
+ "workspace.onboarding.description": "Chacun dispose de son propre checkout pour ne pas interférer avec votre dépôt local",
"workspace.lifecycle.moving": "Déplacement vers l’espace de travail",
"workspace.lifecycle.set": "Espace de travail défini",
+ "session.summary.title": "Détails de la session",
+ "session.summary.noBranch": "Aucune branche",
+ "session.summary.basedOn": "Basé sur {{branch}}",
"workspace.type.local": "local",
"workspace.type.sandbox": "bac à sable",
"workspace.create.failed.title": "Échec de la création de l'espace de travail",
@@ -1217,4 +1226,12 @@ export const dict = {
"titlebar.update": "Mettre à jour",
"titlebar.updateVersion": "Mettre à jour {{version}}",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
}
diff --git a/packages/app/src/i18n/hi.ts b/packages/app/src/i18n/hi.ts
index a704e6233ef..5e6ee8c9f30 100644
--- a/packages/app/src/i18n/hi.ts
+++ b/packages/app/src/i18n/hi.ts
@@ -1147,4 +1147,50 @@ export const dict = {
"workspace.reset.archived.one": "1 सेशन संग्रहित किया जाएगा।",
"workspace.reset.archived.many": "{{count}} सेशन संग्रहित किए जाएँगे।",
"workspace.reset.note": "यह डिफ़ॉल्ट शाखा से मिलान करने के लिए वर्कस्पेस को रीसेट कर देगा।",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "common.viewAll": "View all",
+ "session.new.workspace.local.tooltip": "Use current checkout",
+ "session.new.workspace.new.tooltip": "Create isolated checkout",
+ "session.new.workspace.fromBranch": "from {{branch}}",
+ "session.new.workspace.trigger.tooltip": "Select where to run session",
+ "session.new.workspace.search.placeholder": "Search workspaces",
+ "settings.tab.workspaces": "Workspaces",
+ "settings.workspaces.filter.all": "All projects",
+ "settings.workspaces.empty": "No workspaces",
+ "settings.workspaces.count.one": "{{count}} workspace",
+ "settings.workspaces.count.other": "{{count}} workspaces",
+ "settings.workspaces.sessions.one": "{{count}} session in {{project}}",
+ "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
+ "settings.workspaces.lastActiveSession": "Last active session",
+ "settings.workspaces.deleteAll": "Delete all workspaces",
+ "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
+ "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
+ "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
+ "settings.workspaces.default.title": "Default environment",
+ "settings.workspaces.default.description": "Choose where new sessions start",
+ "settings.workspaces.default.lastUsed": "Last used per project",
+ "settings.workspaces.default.local": "Local directory",
+ "settings.workspaces.default.new": "New workspace",
+ "workspace.move.title": "Move to workspace",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.move.failed": "Failed to move session",
+ "workspace.lifecycle.creating": "Creating workspace",
+ "workspace.lifecycle.created": "Workspace created",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "workspace.lifecycle.moving": "Moving to workspace",
+ "workspace.lifecycle.set": "Workspace set",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
}
diff --git a/packages/app/src/i18n/id.ts b/packages/app/src/i18n/id.ts
index e693869089e..35bbac6ee1b 100644
--- a/packages/app/src/i18n/id.ts
+++ b/packages/app/src/i18n/id.ts
@@ -1234,4 +1234,50 @@ export const dict = {
"workspace.reset.archived.one": "1 sesi akan diarsipkan.",
"workspace.reset.archived.many": "{{count}} sesi akan diarsipkan.",
"workspace.reset.note": "Ini akan mengatur ulang ruang kerja agar cocok dengan cabang bawaan.",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "common.viewAll": "View all",
+ "session.new.workspace.local.tooltip": "Use current checkout",
+ "session.new.workspace.new.tooltip": "Create isolated checkout",
+ "session.new.workspace.fromBranch": "from {{branch}}",
+ "session.new.workspace.trigger.tooltip": "Select where to run session",
+ "session.new.workspace.search.placeholder": "Search workspaces",
+ "settings.tab.workspaces": "Workspaces",
+ "settings.workspaces.filter.all": "All projects",
+ "settings.workspaces.empty": "No workspaces",
+ "settings.workspaces.count.one": "{{count}} workspace",
+ "settings.workspaces.count.other": "{{count}} workspaces",
+ "settings.workspaces.sessions.one": "{{count}} session in {{project}}",
+ "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
+ "settings.workspaces.lastActiveSession": "Last active session",
+ "settings.workspaces.deleteAll": "Delete all workspaces",
+ "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
+ "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
+ "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
+ "settings.workspaces.default.title": "Default environment",
+ "settings.workspaces.default.description": "Choose where new sessions start",
+ "settings.workspaces.default.lastUsed": "Last used per project",
+ "settings.workspaces.default.local": "Local directory",
+ "settings.workspaces.default.new": "New workspace",
+ "workspace.move.title": "Move to workspace",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.move.failed": "Failed to move session",
+ "workspace.lifecycle.creating": "Creating workspace",
+ "workspace.lifecycle.created": "Workspace created",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "workspace.lifecycle.moving": "Moving to workspace",
+ "workspace.lifecycle.set": "Workspace set",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
}
diff --git a/packages/app/src/i18n/it.ts b/packages/app/src/i18n/it.ts
index 3f4089018dd..0caa391f4ba 100644
--- a/packages/app/src/i18n/it.ts
+++ b/packages/app/src/i18n/it.ts
@@ -1160,4 +1160,55 @@ export const dict = {
"desktop.wsl.error.failedPort": "Impossibile ottenere la porta",
"desktop.picker.error.notSelected": "Il file non è stato selezionato nella finestra di selezione",
"desktop.picker.error.sizeLimit": "Gli allegati selezionati superano il limite di {{limit}} MB",
+
+ "common.viewAll": "Mostra tutto",
+ "session.new.workspace.local.tooltip": "Usa il checkout corrente",
+ "session.new.workspace.new.tooltip": "Crea un checkout isolato",
+ "session.new.workspace.fromBranch": "da {{branch}}",
+ "session.new.workspace.trigger.tooltip": "Seleziona dove eseguire la sessione",
+ "session.new.workspace.search.placeholder": "Cerca aree di lavoro",
+ "settings.tab.workspaces": "Aree di lavoro",
+ "settings.workspaces.filter.all": "Tutti i progetti",
+ "settings.workspaces.empty": "Nessuna area di lavoro",
+ "settings.workspaces.count.one": "{{count}} area di lavoro",
+ "settings.workspaces.count.many": "{{count}} aree di lavoro",
+ "settings.workspaces.count.other": "{{count}} aree di lavoro",
+ "settings.workspaces.sessions.one": "{{count}} sessione in {{project}}",
+ "settings.workspaces.sessions.many": "{{count}} sessioni in {{project}}",
+ "settings.workspaces.sessions.other": "{{count}} sessioni in {{project}}",
+ "settings.workspaces.lastActiveSession": "Ultima sessione attiva",
+ "settings.workspaces.deleteAll": "Elimina tutte le aree di lavoro",
+ "settings.workspaces.deleteAll.confirm": "Eliminare tutte le {{count}} aree di lavoro?",
+ "settings.workspaces.delete.warning":
+ "La directory e il branch dell'area di lavoro verranno rimossi definitivamente. L'eliminazione procede solo se l'area è pulita, inattiva e non ha sessioni collegate.",
+ "settings.workspaces.deleteAll.warning":
+ "Le {{count}} aree di lavoro selezionate in {{project}} verranno rimosse definitivamente solo se ciascuna è pulita, inattiva e non ha sessioni collegate.",
+ "settings.workspaces.delete.blocked.active": "L'area di lavoro attiva non può essere eliminata.",
+ "settings.workspaces.delete.blocked.linked": "Questa area di lavoro ha sessioni collegate e non può essere eliminata.",
+ "settings.workspaces.default.title": "Ambiente predefinito",
+ "settings.workspaces.default.description": "Scegli dove avviare le nuove sessioni",
+ "settings.workspaces.default.lastUsed": "Ultimo usato per progetto",
+ "settings.workspaces.default.local": "Directory locale",
+ "settings.workspaces.default.new": "Nuova area di lavoro",
+ "workspace.move.title": "Sposta nell'area di lavoro",
+ "workspace.move.menu.title": "Sposta la sessione in",
+ "workspace.move.failed": "Impossibile spostare la sessione",
+ "workspace.lifecycle.creating": "Creazione dell'area di lavoro",
+ "workspace.lifecycle.created": "Area di lavoro creata",
+ "workspace.lifecycle.starting": "Avvio sessione",
+ "workspace.onboarding.title": "Isola le sessioni con le aree di lavoro",
+ "workspace.onboarding.description": "Ognuna ottiene il proprio checkout per non interferire con il repository locale",
+ "workspace.lifecycle.moving": "Spostamento nell'area di lavoro",
+ "workspace.lifecycle.set": "Area di lavoro impostata",
+ "session.summary.title": "Dettagli della sessione",
+ "session.summary.noBranch": "Nessun branch",
+ "session.summary.basedOn": "Basato su {{branch}}",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
}
diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts
index 0ed09171dd1..69cc4a2fed1 100644
--- a/packages/app/src/i18n/ja.ts
+++ b/packages/app/src/i18n/ja.ts
@@ -1186,4 +1186,19 @@ export const dict = {
"titlebar.update": "アップデート",
"titlebar.updateVersion": "アップデート {{version}}",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
}
diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts
index dd54eb5f893..2c4a0a1debc 100644
--- a/packages/app/src/i18n/ko.ts
+++ b/packages/app/src/i18n/ko.ts
@@ -1184,4 +1184,19 @@ export const dict = {
"desktop.picker.error.notSelected": "선택기에서 파일을 선택하지 않았습니다",
"desktop.picker.error.sizeLimit": "선택한 첨부 파일이 {{limit}} MB 제한을 초과합니다",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
}
diff --git a/packages/app/src/i18n/nl.ts b/packages/app/src/i18n/nl.ts
index 7ac999c7a79..6107e879665 100644
--- a/packages/app/src/i18n/nl.ts
+++ b/packages/app/src/i18n/nl.ts
@@ -1155,4 +1155,50 @@ export const dict = {
"workspace.reset.archived.many": "{{count}} sessies worden gearchiveerd.",
"workspace.reset.note":
"Hierdoor wordt de werkruimte opnieuw ingesteld zodat deze overeenkomt met de standaardbranch.",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "common.viewAll": "View all",
+ "session.new.workspace.local.tooltip": "Use current checkout",
+ "session.new.workspace.new.tooltip": "Create isolated checkout",
+ "session.new.workspace.fromBranch": "from {{branch}}",
+ "session.new.workspace.trigger.tooltip": "Select where to run session",
+ "session.new.workspace.search.placeholder": "Search workspaces",
+ "settings.tab.workspaces": "Workspaces",
+ "settings.workspaces.filter.all": "All projects",
+ "settings.workspaces.empty": "No workspaces",
+ "settings.workspaces.count.one": "{{count}} workspace",
+ "settings.workspaces.count.other": "{{count}} workspaces",
+ "settings.workspaces.sessions.one": "{{count}} session in {{project}}",
+ "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
+ "settings.workspaces.lastActiveSession": "Last active session",
+ "settings.workspaces.deleteAll": "Delete all workspaces",
+ "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
+ "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
+ "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
+ "settings.workspaces.default.title": "Default environment",
+ "settings.workspaces.default.description": "Choose where new sessions start",
+ "settings.workspaces.default.lastUsed": "Last used per project",
+ "settings.workspaces.default.local": "Local directory",
+ "settings.workspaces.default.new": "New workspace",
+ "workspace.move.title": "Move to workspace",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.move.failed": "Failed to move session",
+ "workspace.lifecycle.creating": "Creating workspace",
+ "workspace.lifecycle.created": "Workspace created",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "workspace.lifecycle.moving": "Moving to workspace",
+ "workspace.lifecycle.set": "Workspace set",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
}
diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts
index 3350d0582e3..cd71b4819d2 100644
--- a/packages/app/src/i18n/no.ts
+++ b/packages/app/src/i18n/no.ts
@@ -1284,4 +1284,19 @@ export const dict = {
"settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome",
"settings.updates.action.downloading": "Laster ned...",
"settings.updates.action.installing": "Installerer...",
-} satisfies Partial>
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
+}
diff --git a/packages/app/src/i18n/pa.ts b/packages/app/src/i18n/pa.ts
index 4e0ff16f87e..1609ee78240 100644
--- a/packages/app/src/i18n/pa.ts
+++ b/packages/app/src/i18n/pa.ts
@@ -1145,4 +1145,50 @@ export const dict = {
"workspace.reset.archived.one": "1 سیشن آرکائیو کیتا جائے گا۔",
"workspace.reset.archived.many": "{{count}} سیشن آرکائیو کیتے جان گے۔",
"workspace.reset.note": "ایہ ورک اسپیس نو ڈیفالٹ برانچ نال ملاون لئی ری سیٹ کرے گا۔",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "common.viewAll": "View all",
+ "session.new.workspace.local.tooltip": "Use current checkout",
+ "session.new.workspace.new.tooltip": "Create isolated checkout",
+ "session.new.workspace.fromBranch": "from {{branch}}",
+ "session.new.workspace.trigger.tooltip": "Select where to run session",
+ "session.new.workspace.search.placeholder": "Search workspaces",
+ "settings.tab.workspaces": "Workspaces",
+ "settings.workspaces.filter.all": "All projects",
+ "settings.workspaces.empty": "No workspaces",
+ "settings.workspaces.count.one": "{{count}} workspace",
+ "settings.workspaces.count.other": "{{count}} workspaces",
+ "settings.workspaces.sessions.one": "{{count}} session in {{project}}",
+ "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
+ "settings.workspaces.lastActiveSession": "Last active session",
+ "settings.workspaces.deleteAll": "Delete all workspaces",
+ "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
+ "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
+ "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
+ "settings.workspaces.default.title": "Default environment",
+ "settings.workspaces.default.description": "Choose where new sessions start",
+ "settings.workspaces.default.lastUsed": "Last used per project",
+ "settings.workspaces.default.local": "Local directory",
+ "settings.workspaces.default.new": "New workspace",
+ "workspace.move.title": "Move to workspace",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.move.failed": "Failed to move session",
+ "workspace.lifecycle.creating": "Creating workspace",
+ "workspace.lifecycle.created": "Workspace created",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "workspace.lifecycle.moving": "Moving to workspace",
+ "workspace.lifecycle.set": "Workspace set",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
}
diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts
index 1eaa8d5cdb7..b199ad928bc 100644
--- a/packages/app/src/i18n/pl.ts
+++ b/packages/app/src/i18n/pl.ts
@@ -1053,8 +1053,12 @@ export const dict = {
"settings.workspaces.filter.all": "Wszystkie projekty",
"settings.workspaces.empty": "Brak obszarów roboczych",
"settings.workspaces.count.one": "{{count}} obszar roboczy",
+ "settings.workspaces.count.few": "{{count}} obszary robocze",
+ "settings.workspaces.count.many": "{{count}} obszarów roboczych",
"settings.workspaces.count.other": "{{count}} obszarów roboczych",
"settings.workspaces.sessions.one": "{{count}} sesja w {{project}}",
+ "settings.workspaces.sessions.few": "{{count}} sesje w {{project}}",
+ "settings.workspaces.sessions.many": "{{count}} sesji w {{project}}",
"settings.workspaces.sessions.other": "{{count}} sesji w {{project}}",
"settings.workspaces.lastActiveSession": "Ostatnia aktywna sesja",
"settings.workspaces.deleteAll": "Usuń wszystkie obszary robocze",
@@ -1071,11 +1075,18 @@ export const dict = {
"settings.workspaces.default.local": "Katalog lokalny",
"settings.workspaces.default.new": "Nowy obszar roboczy",
"workspace.move.title": "Przenieś do obszaru roboczego",
+ "workspace.move.menu.title": "Przenieś sesję do",
"workspace.move.failed": "Nie udało się przenieść sesji",
"workspace.lifecycle.creating": "Tworzenie obszaru roboczego",
"workspace.lifecycle.created": "Utworzono obszar roboczy",
+ "workspace.lifecycle.starting": "Uruchamianie sesji",
+ "workspace.onboarding.title": "Izoluj sesje za pomocą przestrzeni roboczych",
+ "workspace.onboarding.description": "Każda otrzymuje własny checkout, więc nic nie zakłóca Twojego lokalnego repozytorium",
"workspace.lifecycle.moving": "Przenoszenie do obszaru roboczego",
"workspace.lifecycle.set": "Ustawiono obszar roboczy",
+ "session.summary.title": "Szczegóły sesji",
+ "session.summary.noBranch": "Brak gałęzi",
+ "session.summary.basedOn": "Na podstawie {{branch}}",
"workspace.type.local": "lokalna",
"workspace.type.sandbox": "piaskownica",
"workspace.create.failed.title": "Nie udało się utworzyć przestrzeni roboczej",
@@ -1201,4 +1212,12 @@ export const dict = {
"error.childStore.persistedProjectIconCreateFailed": "Nie udało się utworzyć trwałej ikony projektu",
"error.childStore.storeCreateFailed": "Nie udało się utworzyć magazynu",
"terminal.connectionLost.abnormalClose": "WebSocket zamknięty nieprawidłowo: {{code}}",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
}
diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts
index d2f0e1a893f..4719071092d 100644
--- a/packages/app/src/i18n/ru.ts
+++ b/packages/app/src/i18n/ru.ts
@@ -1132,8 +1132,12 @@ export const dict = {
"settings.workspaces.filter.all": "Все проекты",
"settings.workspaces.empty": "Нет рабочих пространств",
"settings.workspaces.count.one": "{{count}} рабочее пространство",
+ "settings.workspaces.count.few": "{{count}} рабочих пространства",
+ "settings.workspaces.count.many": "{{count}} рабочих пространств",
"settings.workspaces.count.other": "{{count}} рабочих пространств",
"settings.workspaces.sessions.one": "{{count}} сессия в {{project}}",
+ "settings.workspaces.sessions.few": "{{count}} сессии в {{project}}",
+ "settings.workspaces.sessions.many": "{{count}} сессий в {{project}}",
"settings.workspaces.sessions.other": "{{count}} сессий в {{project}}",
"settings.workspaces.lastActiveSession": "Последний активный сеанс",
"settings.workspaces.deleteAll": "Удалить все рабочие пространства",
@@ -1151,11 +1155,18 @@ export const dict = {
"settings.workspaces.default.local": "Локальный каталог",
"settings.workspaces.default.new": "Новое рабочее пространство",
"workspace.move.title": "Переместить в рабочую область",
+ "workspace.move.menu.title": "Переместить сессию в",
"workspace.move.failed": "Не удалось переместить сеанс",
"workspace.lifecycle.creating": "Создание рабочей области",
"workspace.lifecycle.created": "Рабочая область создана",
+ "workspace.lifecycle.starting": "Запуск сессии",
+ "workspace.onboarding.title": "Изолируйте сессии с помощью рабочих пространств",
+ "workspace.onboarding.description": "Каждая получает отдельную копию, чтобы не влиять на ваш локальный репозиторий",
"workspace.lifecycle.moving": "Перемещение в рабочую область",
"workspace.lifecycle.set": "Рабочая область задана",
+ "session.summary.title": "Сведения о сеансе",
+ "session.summary.noBranch": "Нет ветки",
+ "session.summary.basedOn": "На основе {{branch}}",
"workspace.type.local": "локальное",
"workspace.type.sandbox": "песочница",
"workspace.create.failed.title": "Не удалось создать рабочее пространство",
@@ -1280,4 +1291,12 @@ export const dict = {
"error.childStore.persistedProjectIconCreateFailed": "Не удалось создать постоянный значок проекта",
"error.childStore.storeCreateFailed": "Не удалось создать хранилище",
"terminal.connectionLost.abnormalClose": "WebSocket закрыт аварийно: {{code}}",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
}
diff --git a/packages/app/src/i18n/sv.ts b/packages/app/src/i18n/sv.ts
index ab5007238b6..9bab2f8502d 100644
--- a/packages/app/src/i18n/sv.ts
+++ b/packages/app/src/i18n/sv.ts
@@ -1144,4 +1144,50 @@ export const dict = {
"workspace.reset.archived.one": "1 session kommer att arkiveras.",
"workspace.reset.archived.many": "{{count}} sessioner kommer att arkiveras.",
"workspace.reset.note": "Detta kommer att återställa arbetsytan så att den matchar standardgrenen.",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "common.viewAll": "View all",
+ "session.new.workspace.local.tooltip": "Use current checkout",
+ "session.new.workspace.new.tooltip": "Create isolated checkout",
+ "session.new.workspace.fromBranch": "from {{branch}}",
+ "session.new.workspace.trigger.tooltip": "Select where to run session",
+ "session.new.workspace.search.placeholder": "Search workspaces",
+ "settings.tab.workspaces": "Workspaces",
+ "settings.workspaces.filter.all": "All projects",
+ "settings.workspaces.empty": "No workspaces",
+ "settings.workspaces.count.one": "{{count}} workspace",
+ "settings.workspaces.count.other": "{{count}} workspaces",
+ "settings.workspaces.sessions.one": "{{count}} session in {{project}}",
+ "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
+ "settings.workspaces.lastActiveSession": "Last active session",
+ "settings.workspaces.deleteAll": "Delete all workspaces",
+ "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
+ "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
+ "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
+ "settings.workspaces.default.title": "Default environment",
+ "settings.workspaces.default.description": "Choose where new sessions start",
+ "settings.workspaces.default.lastUsed": "Last used per project",
+ "settings.workspaces.default.local": "Local directory",
+ "settings.workspaces.default.new": "New workspace",
+ "workspace.move.title": "Move to workspace",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.move.failed": "Failed to move session",
+ "workspace.lifecycle.creating": "Creating workspace",
+ "workspace.lifecycle.created": "Workspace created",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "workspace.lifecycle.moving": "Moving to workspace",
+ "workspace.lifecycle.set": "Workspace set",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
}
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index a4713107096..94e0d2ae993 100644
--- a/packages/app/src/i18n/th.ts
+++ b/packages/app/src/i18n/th.ts
@@ -1255,4 +1255,19 @@ export const dict = {
"error.childStore.persistedProjectIconCreateFailed": "ไม่สามารถสร้างไอคอนโปรเจกต์ถาวร",
"error.childStore.storeCreateFailed": "ไม่สามารถสร้างที่เก็บ",
"terminal.connectionLost.abnormalClose": "WebSocket ปิดอย่างผิดปกติ: {{code}}",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
}
diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts
index aa02f982821..fe35be7ad32 100644
--- a/packages/app/src/i18n/tr.ts
+++ b/packages/app/src/i18n/tr.ts
@@ -1277,4 +1277,19 @@ export const dict = {
"error.childStore.persistedProjectIconCreateFailed": "Kalıcı proje simgesi oluşturulamadı",
"error.childStore.storeCreateFailed": "Depo oluşturulamadı",
"terminal.connectionLost.abnormalClose": "WebSocket anormal şekilde kapandı: {{code}}",
-} satisfies Partial>
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
+}
diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts
index e089b946d7c..57fc2969cf0 100644
--- a/packages/app/src/i18n/uk.ts
+++ b/packages/app/src/i18n/uk.ts
@@ -1237,8 +1237,12 @@ export const dict = {
"settings.workspaces.filter.all": "Усі проєкти",
"settings.workspaces.empty": "Немає робочих просторів",
"settings.workspaces.count.one": "{{count}} робочий простір",
+ "settings.workspaces.count.few": "{{count}} робочі простори",
+ "settings.workspaces.count.many": "{{count}} робочих просторів",
"settings.workspaces.count.other": "{{count}} робочих просторів",
"settings.workspaces.sessions.one": "{{count}} сесія в {{project}}",
+ "settings.workspaces.sessions.few": "{{count}} сесії в {{project}}",
+ "settings.workspaces.sessions.many": "{{count}} сесій у {{project}}",
"settings.workspaces.sessions.other": "{{count}} сесій у {{project}}",
"settings.workspaces.lastActiveSession": "Останній активний сеанс",
"settings.workspaces.deleteAll": "Видалити всі робочі простори",
@@ -1255,11 +1259,18 @@ export const dict = {
"settings.workspaces.default.local": "Локальний каталог",
"settings.workspaces.default.new": "Новий робочий простір",
"workspace.move.title": "Перемістити до робочого простору",
+ "workspace.move.menu.title": "Перемістити сесію до",
"workspace.move.failed": "Не вдалося перемістити сеанс",
"workspace.lifecycle.creating": "Створення робочого простору",
"workspace.lifecycle.created": "Робочий простір створено",
+ "workspace.lifecycle.starting": "Запуск сесії",
+ "workspace.onboarding.title": "Ізолюйте сесії за допомогою робочих просторів",
+ "workspace.onboarding.description": "Кожен отримує власний checkout, тому ніщо не заважає вашому локальному репозиторію",
"workspace.lifecycle.moving": "Переміщення до робочого простору",
"workspace.lifecycle.set": "Робочий простір установлено",
+ "session.summary.title": "Відомості про сеанс",
+ "session.summary.noBranch": "Немає гілки",
+ "session.summary.basedOn": "На основі {{branch}}",
"workspace.type.local": "локальна",
"workspace.type.sandbox": "пісочниця",
"workspace.create.failed.title": "Не вдалося створити робочу область",
@@ -1284,4 +1295,12 @@ export const dict = {
"workspace.reset.archived.one": "1 сесію буде заархівовано.",
"workspace.reset.archived.many": "{{count}} сесій буде заархівовано.",
"workspace.reset.note": "Це скине робочу область, щоб вона відповідала гілці за замовчуванням.",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
}
diff --git a/packages/app/src/i18n/ur.ts b/packages/app/src/i18n/ur.ts
index 661c013ebe7..1f536ef0d87 100644
--- a/packages/app/src/i18n/ur.ts
+++ b/packages/app/src/i18n/ur.ts
@@ -1148,4 +1148,50 @@ export const dict = {
"workspace.reset.archived.one": "1 سیشن آرکائیو کیا جائے گا۔",
"workspace.reset.archived.many": "{{count}} سیشنز آرکائیو کیے جائیں گے۔",
"workspace.reset.note": "یہ ڈیفالٹ برانچ سے ملنے کے لیے ورک اسپیس کو دوبارہ ترتیب دے گا۔",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "common.viewAll": "View all",
+ "session.new.workspace.local.tooltip": "Use current checkout",
+ "session.new.workspace.new.tooltip": "Create isolated checkout",
+ "session.new.workspace.fromBranch": "from {{branch}}",
+ "session.new.workspace.trigger.tooltip": "Select where to run session",
+ "session.new.workspace.search.placeholder": "Search workspaces",
+ "settings.tab.workspaces": "Workspaces",
+ "settings.workspaces.filter.all": "All projects",
+ "settings.workspaces.empty": "No workspaces",
+ "settings.workspaces.count.one": "{{count}} workspace",
+ "settings.workspaces.count.other": "{{count}} workspaces",
+ "settings.workspaces.sessions.one": "{{count}} session in {{project}}",
+ "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
+ "settings.workspaces.lastActiveSession": "Last active session",
+ "settings.workspaces.deleteAll": "Delete all workspaces",
+ "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
+ "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
+ "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
+ "settings.workspaces.default.title": "Default environment",
+ "settings.workspaces.default.description": "Choose where new sessions start",
+ "settings.workspaces.default.lastUsed": "Last used per project",
+ "settings.workspaces.default.local": "Local directory",
+ "settings.workspaces.default.new": "New workspace",
+ "workspace.move.title": "Move to workspace",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.move.failed": "Failed to move session",
+ "workspace.lifecycle.creating": "Creating workspace",
+ "workspace.lifecycle.created": "Workspace created",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "workspace.lifecycle.moving": "Moving to workspace",
+ "workspace.lifecycle.set": "Workspace set",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
}
diff --git a/packages/app/src/i18n/vi.ts b/packages/app/src/i18n/vi.ts
index 1e243ba17d6..516b05d823b 100644
--- a/packages/app/src/i18n/vi.ts
+++ b/packages/app/src/i18n/vi.ts
@@ -1153,4 +1153,50 @@ export const dict = {
"workspace.reset.archived.one": "1 phiên sẽ được lưu trữ.",
"workspace.reset.archived.many": "{{count}} phiên sẽ được lưu trữ.",
"workspace.reset.note": "Điều này sẽ thiết lập lại không gian làm việc để phù hợp với nhánh mặc định.",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "common.viewAll": "View all",
+ "session.new.workspace.local.tooltip": "Use current checkout",
+ "session.new.workspace.new.tooltip": "Create isolated checkout",
+ "session.new.workspace.fromBranch": "from {{branch}}",
+ "session.new.workspace.trigger.tooltip": "Select where to run session",
+ "session.new.workspace.search.placeholder": "Search workspaces",
+ "settings.tab.workspaces": "Workspaces",
+ "settings.workspaces.filter.all": "All projects",
+ "settings.workspaces.empty": "No workspaces",
+ "settings.workspaces.count.one": "{{count}} workspace",
+ "settings.workspaces.count.other": "{{count}} workspaces",
+ "settings.workspaces.sessions.one": "{{count}} session in {{project}}",
+ "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
+ "settings.workspaces.lastActiveSession": "Last active session",
+ "settings.workspaces.deleteAll": "Delete all workspaces",
+ "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
+ "settings.workspaces.delete.warning": "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.deleteAll.warning": "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
+ "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
+ "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
+ "settings.workspaces.default.title": "Default environment",
+ "settings.workspaces.default.description": "Choose where new sessions start",
+ "settings.workspaces.default.lastUsed": "Last used per project",
+ "settings.workspaces.default.local": "Local directory",
+ "settings.workspaces.default.new": "New workspace",
+ "workspace.move.title": "Move to workspace",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.move.failed": "Failed to move session",
+ "workspace.lifecycle.creating": "Creating workspace",
+ "workspace.lifecycle.created": "Workspace created",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "workspace.lifecycle.moving": "Moving to workspace",
+ "workspace.lifecycle.set": "Workspace set",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
}
diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts
index bddf98a4259..b9e5f6f921f 100644
--- a/packages/app/src/i18n/zh.ts
+++ b/packages/app/src/i18n/zh.ts
@@ -1244,4 +1244,19 @@ export const dict = {
"error.childStore.persistedProjectIconCreateFailed": "创建持久化项目图标失败",
"error.childStore.storeCreateFailed": "创建存储失败",
"terminal.connectionLost.abnormalClose": "WebSocket 异常关闭:{{code}}",
-} satisfies Partial>
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
+}
diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts
index d3df431e8a6..81c390b81b9 100644
--- a/packages/app/src/i18n/zht.ts
+++ b/packages/app/src/i18n/zht.ts
@@ -1240,4 +1240,19 @@ export const dict = {
"error.childStore.persistedProjectIconCreateFailed": "建立持續性專案圖示失敗",
"error.childStore.storeCreateFailed": "建立儲存區失敗",
"terminal.connectionLost.abnormalClose": "WebSocket 異常關閉:{{code}}",
-} satisfies Partial>
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
+ "context.export.session": "Export session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+ "common.export": "Export",
+ "workspace.move.menu.title": "Move session to",
+ "workspace.lifecycle.starting": "Starting session",
+ "workspace.onboarding.title": "Isolate sessions with workspaces",
+ "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
+ "session.summary.title": "Session details",
+ "session.summary.noBranch": "No branch",
+ "session.summary.basedOn": "Based on {{branch}}",
+}
diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx
index 3c84b1d58ef..4e1373bf0f8 100644
--- a/packages/app/src/pages/new-session/new-session-view.tsx
+++ b/packages/app/src/pages/new-session/new-session-view.tsx
@@ -1,6 +1,6 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { Icon } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
import { Show, createMemo, createSignal, type Accessor } from "solid-js"
@@ -33,10 +33,7 @@ export function NewSessionView(props: {
}) {
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
- createStore({ cardDismissed: false, used: false }),
- )
- const showCard = createMemo(
- () => onboardingReady() && props.workspace.bar.visible() && !onboarding.cardDismissed && !onboarding.used,
+ createStore({ used: false }),
)
const select = (value: string) => {
props.workspace.selection.set(value)
@@ -151,7 +148,7 @@ function ProviderTip() {
>
{language.t("home.providerTip")}
-
+
setPersistedState("dismissedAt", Date.now())}
>
-
+
diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx
index 4ceb43dfa33..d4591bcd2f3 100644
--- a/packages/app/src/pages/session/timeline/message-timeline.tsx
+++ b/packages/app/src/pages/session/timeline/message-timeline.tsx
@@ -290,7 +290,7 @@ function WorkspaceMoveAction(props: {
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pr-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
}
>
-
+
{language.t("workspace.move.title")}
-
+
{
expect(isWorkspaceDirectory(project, "C:\\repo")).toBe(false)
expect(isWorkspaceDirectory(project, "C:\\repo-workspaces\\feature")).toBe(true)
expect(isWorkspaceDirectory(project, "c:\\repo-workspaces\\feature\\packages\\app")).toBe(true)
+ expect(
+ isWorkspaceDirectory(
+ { worktree: "/repo", sandboxes: ["/repo/.worktrees/feature"] },
+ "/repo/.worktrees/feature",
+ ),
+ ).toBe(true)
})
test("does not classify unknown directories", () => {
@@ -156,6 +162,13 @@ test("blocks unsafe workspace deletion", () => {
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "dirty" })).toBe("dirty")
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "unknown" })).toBe("unknown")
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "clean" })).toBe("safe")
+ expect(
+ inspectWorkspaceDeletion({
+ workspace: "/workspace",
+ sessions: [{ directory: "/workspace", time: { created: 1, updated: 1, archived: 2 } } as Session],
+ status: "clean",
+ }),
+ ).toBe("safe")
})
test("groups nested non-archived workspace sessions by latest activity", () => {
diff --git a/packages/app/src/utils/workspace.ts b/packages/app/src/utils/workspace.ts
index 38c5890ba1b..acb8fa3247d 100644
--- a/packages/app/src/utils/workspace.ts
+++ b/packages/app/src/utils/workspace.ts
@@ -73,14 +73,20 @@ export function inspectWorkspaceDeletion(input: {
status: "clean" | "dirty" | "unknown"
}): WorkspaceDeleteInspection {
if (input.activeDirectory && containsDirectory(input.workspace, input.activeDirectory)) return "active"
- if (input.sessions.some((session) => containsDirectory(input.workspace, session.directory))) return "linked"
+ if (
+ input.sessions.some(
+ (session) => session.time?.archived === undefined && containsDirectory(input.workspace, session.directory),
+ )
+ )
+ return "linked"
if (input.status === "unknown") return "unknown"
if (input.status === "dirty") return "dirty"
return "safe"
}
export function isWorkspaceDirectory(project: WorkspaceProject | undefined, directory: string) {
- if (!project || containsDirectory(project.worktree, directory)) return false
+ if (!project || (containsDirectory(project.worktree, directory) && containsDirectory(directory, project.worktree)))
+ return false
return workspaceDirectories(project).some((workspace) => containsDirectory(workspace, directory))
}
From 5005898468d5076503cc99535287d493effe8ad7 Mon Sep 17 00:00:00 2001
From: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Date: Thu, 6 Aug 2026 13:19:06 +1000
Subject: [PATCH 14/22] fix(app): support fallback local workspace state when
draft id is absent
---
packages/app/src/pages/new-session.tsx | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx
index 8ceced9e840..0bf9d0a8533 100644
--- a/packages/app/src/pages/new-session.tsx
+++ b/packages/app/src/pages/new-session.tsx
@@ -4,7 +4,7 @@ import { useTitlebarRightMount } from "@/components/titlebar"
import { useSettings } from "@/context/settings"
import { useTabs, type DraftTab } from "@/context/tabs"
import { useSearchParams } from "@solidjs/router"
-import { createEffect, createMemo, createResource } from "solid-js"
+import { createEffect, createMemo, createResource, createSignal } from "solid-js"
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
@@ -17,13 +17,18 @@ export default function NewSessionPage() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
const openWorkspaces = useSettingsDialog("workspaces")
+ const [localWorktree, setLocalWorktree] = createSignal()
const draftTab = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const workspace = createNewSessionWorkspaceController({
- selected: () => draftTab()?.worktree,
+ selected: () => (search.draftId ? draftTab()?.worktree : localWorktree()),
setSelected: (worktree) => {
- if (search.draftId) tabs.updateDraft(search.draftId, { worktree })
+ if (search.draftId) {
+ tabs.updateDraft(search.draftId, { worktree })
+ return
+ }
+ setLocalWorktree(worktree)
},
onViewAll: openWorkspaces,
})
From 1b99e64e67261dd63b253b44db1c6bb261a491ba Mon Sep 17 00:00:00 2001
From: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Date: Thu, 6 Aug 2026 15:14:44 +1000
Subject: [PATCH 15/22] refactor(app): simplify workspace flows and i18n
---
packages/app/AGENTS.md | 2 +
.../session-timeline-transport.spec.ts | 12 +-
.../app/e2e/regression/workspaces.spec.ts | 529 ++--------------
packages/app/e2e/utils/sse-transport.ts | 53 +-
packages/app/src/app.tsx | 9 +-
.../components/prompt-input/submit.test.ts | 568 ++----------------
.../app/src/components/prompt-input/submit.ts | 8 +-
.../src/components/session-workspace-menu.tsx | 39 +-
.../src/components/settings-v2/workspaces.tsx | 79 +--
.../context/global-sync/event-reducer.test.ts | 51 --
packages/app/src/context/language.tsx | 41 +-
packages/app/src/context/server-sdk.test.ts | 85 +--
packages/app/src/context/server-sdk.tsx | 50 +-
.../app/src/context/server-session.test.ts | 35 --
packages/app/src/context/server-sync.test.ts | 36 --
packages/app/src/i18n/ar.ts | 57 --
packages/app/src/i18n/az.ts | 46 --
packages/app/src/i18n/br.ts | 52 --
packages/app/src/i18n/bs.ts | 50 --
packages/app/src/i18n/da.ts | 48 --
packages/app/src/i18n/de.ts | 51 +-
packages/app/src/i18n/es.ts | 51 --
packages/app/src/i18n/fi.ts | 46 --
packages/app/src/i18n/fr.ts | 51 --
packages/app/src/i18n/hi.ts | 46 --
packages/app/src/i18n/id.ts | 46 --
packages/app/src/i18n/it.ts | 51 --
packages/app/src/i18n/ja.ts | 48 --
packages/app/src/i18n/ko.ts | 48 --
packages/app/src/i18n/nl.ts | 46 --
packages/app/src/i18n/no.ts | 50 +-
packages/app/src/i18n/pa.ts | 46 --
packages/app/src/i18n/parity.test.ts | 88 +--
packages/app/src/i18n/pl.ts | 52 --
packages/app/src/i18n/ru.ts | 53 --
packages/app/src/i18n/sv.ts | 46 --
packages/app/src/i18n/th.ts | 48 --
packages/app/src/i18n/tr.ts | 50 +-
packages/app/src/i18n/uk.ts | 52 --
packages/app/src/i18n/ur.ts | 46 --
packages/app/src/i18n/vi.ts | 46 --
packages/app/src/i18n/zh.ts | 50 +-
packages/app/src/i18n/zht.ts | 50 +-
packages/app/src/pages/new-session.tsx | 11 +-
packages/app/src/pages/session.tsx | 2 +-
.../src/pages/session/session-side-panel.tsx | 5 +-
.../session/timeline/message-timeline.tsx | 39 +-
.../session/timeline/rows-current.test.ts | 32 -
.../app/src/utils/workspace-operation.test.ts | 26 +-
packages/app/src/utils/workspace-operation.ts | 26 +-
.../app/src/utils/workspace-request.test.ts | 36 --
packages/app/src/utils/workspace.test.ts | 139 +----
packages/app/src/utils/workspace.ts | 27 +-
packages/enterprise/src/app.tsx | 25 +-
packages/session-ui/AGENTS.md | 2 +
.../src/components/message-part.tsx | 6 +-
.../src/components/tool-count-label.tsx | 8 +-
packages/ui/AGENTS.md | 2 +
packages/ui/src/context/i18n.tsx | 31 +-
packages/ui/src/i18n/en.ts | 13 +-
60 files changed, 394 insertions(+), 3047 deletions(-)
delete mode 100644 packages/app/src/utils/workspace-request.test.ts
diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md
index 2f587ea73d5..6aad884e755 100644
--- a/packages/app/AGENTS.md
+++ b/packages/app/AGENTS.md
@@ -22,6 +22,8 @@
## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
+- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
+- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts
index 35980499787..1bc2e755949 100644
--- a/packages/app/e2e/regression/session-timeline-transport.spec.ts
+++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts
@@ -99,13 +99,15 @@ test("reconnects after a stream error", async ({ page }) => {
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { protocol: "v2" })
- const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
- id: "timeline-event-7",
- })
+ const first = await timeline.transport.send(
+ partUpdated(textPart("prt_transport_id", "event with id")),
+ { id: "timeline-event-7" },
+ "/api/event",
+ )
await timeline.waitForPart("prt_transport_id")
- await timeline.transport.error("retry with event id")
- const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
+ await timeline.transport.error("retry with event id", "/api/event")
+ const connection = await timeline.transport.waitForConnection({ after: first.connectionID, path: "/api/event" })
expect(first.eventID).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBeUndefined()
diff --git a/packages/app/e2e/regression/workspaces.spec.ts b/packages/app/e2e/regression/workspaces.spec.ts
index a193097d4c0..d0c90767b62 100644
--- a/packages/app/e2e/regression/workspaces.spec.ts
+++ b/packages/app/e2e/regression/workspaces.spec.ts
@@ -63,7 +63,7 @@ async function init(page: Page, tab: Record) {
)
}
-test("selects local, new, and existing workspaces from the ready-ish start menu", async ({ page }) => {
+test("selects an existing workspace from the start menu", async ({ page }) => {
const draftID = "draft_workspaces"
await mockOpenCodeServer(page, {
directory: root,
@@ -77,98 +77,23 @@ test("selects local, new, and existing workspaces from the ready-ish start menu"
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
- const trigger = page.getByRole("button", { name: /^local$/i })
- const dot = page.locator('[data-slot="workspace-onboarding-dot"]')
- await expect(trigger).toBeVisible()
- await expect(dot).toBeVisible()
- await trigger.hover()
- await expect(page.getByRole("tooltip")).toContainText("Isolate sessions with workspaces")
- await trigger.click()
- await expect(page.getByRole("menuitem", { name: /Local repository/ })).toBeVisible()
- await page.getByRole("menuitem", { name: /Local repository/ }).click()
- await expect(dot).toBeVisible()
-
- await trigger.click()
- const newWorkspace = page.getByRole("menuitem", { name: /New workspace/ })
- await expect(newWorkspace).toBeVisible()
- const workspaceTrigger = page.getByRole("menuitem", { name: /Workspace/ })
- await expect(workspaceTrigger).toBeVisible()
- await workspaceTrigger.hover()
- await expect(page.getByRole("menuitem", { name: "View all" })).toBeVisible()
-
- await newWorkspace.click()
- await expect(dot).toBeHidden()
- await expect(page.getByRole("button", { name: /New workspace/ })).toBeVisible()
- await expect(page.getByText("from main", { exact: true })).toBeVisible()
-
- await page.getByRole("button", { name: /New workspace/ }).click()
+ await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("menuitem", { name: /Workspace/ }).hover()
await page.getByRole("menuitem", { name: "feature" }).click()
await expect(page.getByRole("button", { name: /feature/ })).toBeVisible()
})
-test("searches long workspace lists within the available viewport", async ({ page }) => {
- const draftID = "draft_workspace_search"
- const workspaces = Array.from({ length: 10 }, (_, index) => `${workspace}-${index}`)
- await mockOpenCodeServer(page, {
- directory: root,
- project: { ...project, sandboxes: workspaces },
- provider,
- sessions: [],
- pageMessages: () => ({ items: [] }),
- })
- await init(page, { type: "draft", draftID, directory: root })
- await page.setViewportSize({ width: 600, height: 360 })
-
- await page.goto(`/new-session?draftId=${draftID}`)
- await expectAppVisible(page.locator('[data-component="prompt-input"]'))
- await page.getByRole("button", { name: /local|new workspace/i }).click()
- await page.getByRole("menuitem", { name: /Workspace/ }).focus()
- await page.keyboard.press("ArrowRight")
-
- const submenu = page
- .locator('[data-component="menu-v2-content"]')
- .filter({ has: page.getByPlaceholder("Search workspaces") })
- const search = submenu.getByPlaceholder("Search workspaces")
- await expect(search).toBeFocused()
- const box = await submenu.boundingBox()
- expect(box).not.toBeNull()
- expect(box!.y).toBeGreaterThanOrEqual(0)
- expect(box!.y + box!.height).toBeLessThanOrEqual(360)
-
- await search.fill("feature-9")
- await expect(submenu.getByRole("menuitem", { name: "feature-9" })).toBeVisible()
- await expect(submenu.getByRole("menuitem", { name: "feature-0" })).toHaveCount(0)
-})
-
test("lists and manually deletes workspaces from settings", async ({ page }) => {
const draftID = "draft_workspace_settings"
const cleanWorkspace = `${workspace}-clean`
- const inventory = { ...project, sandboxes: [workspace, cleanWorkspace] }
- const session = {
- id: "ses_workspace_settings",
- slug: "workspace-settings",
- projectID: project.id,
- directory: workspace,
- title: "Workspace settings session",
- version: "dev",
- time: { created: 1, updated: 2 },
- }
- let removal: { directory: string | null; body: unknown } | undefined
- let statusRequests = 0
- let sessionListRequests = 0
- page.on("request", (request) => {
- const path = new URL(request.url()).pathname
- if (path === "/vcs/status" || path === "/api/vcs/status") statusRequests++
- if (path === "/session" || path === "/api/session") sessionListRequests++
- })
+ const inventory = { ...project, sandboxes: [cleanWorkspace] }
const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server })
await mockOpenCodeServer(page, {
directory: root,
project: inventory,
provider,
- sessions: [session],
+ sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.route("**/experimental/worktree**", async (route) => {
@@ -180,14 +105,12 @@ test("lists and manually deletes workspaces from settings", async ({ page }) =>
return
}
if (route.request().method() !== "DELETE") return route.fallback()
- const url = new URL(route.request().url())
- removal = { directory: url.searchParams.get("directory"), body: route.request().postDataJSON() }
await transport.send({
directory: "global",
payload: {
id: "evt_workspace_deleted_settings",
type: "project.updated",
- properties: { ...inventory, sandboxes: [workspace] },
+ properties: { ...inventory, sandboxes: [] },
},
})
await route.fulfill({
@@ -209,257 +132,20 @@ test("lists and manually deletes workspaces from settings", async ({ page }) =>
const settings = page.locator(".settings-v2-dialog")
await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected")
- await expect(settings.getByText(workspace, { exact: true })).toBeVisible()
await expect(settings.getByText(cleanWorkspace, { exact: true })).toBeVisible()
- await expect(settings.getByText("Workspace settings session", { exact: true })).toBeVisible()
- await expect(settings.getByText("Automatically delete old workspaces")).toHaveCount(0)
- const firstSessionInventory = sessionListRequests
- await page.keyboard.press("Escape")
- await expect(settings).toHaveCount(0)
- await page.getByRole("button", { name: /local|new workspace/i }).click()
- if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
- await page.getByRole("menuitem", { name: "View all" }).click()
- await expect(settings.getByText("Workspace settings session", { exact: true })).toBeVisible()
- expect(sessionListRequests).toBeGreaterThan(firstSessionInventory)
- await page.setViewportSize({ width: 375, height: 700 })
- expect(await settings.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
- await page.setViewportSize({ width: 1280, height: 720 })
-
- await settings.getByRole("button", { name: "More options" }).click()
- await page.getByRole("menuitem", { name: "Delete all workspaces" }).click()
- const bulkConfirmation = page.locator('[data-dialog-layer="1"]')
- await expect(bulkConfirmation.getByText("Delete all 2 workspaces?")).toBeVisible()
- await expect(bulkConfirmation).toContainText("All projects")
- await bulkConfirmation.getByRole("button", { name: "Cancel" }).click()
-
- await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click()
- let confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' })
- await expect(confirmation).toContainText("linked sessions")
- await expect(confirmation.getByRole("button", { name: "Delete workspace" })).toBeDisabled()
- const firstInspection = statusRequests
- await confirmation.getByRole("button", { name: "Cancel" }).click()
-
- await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click()
- confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' })
- await expect(confirmation).toContainText("linked sessions")
- expect(statusRequests).toBeGreaterThan(firstInspection)
- await confirmation.getByRole("button", { name: "Cancel" }).click()
await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click()
- confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature-clean"?' })
- await expect(confirmation).toContainText("permanently removed")
- const beforeRemoval = statusRequests
+ const confirmation = page
+ .locator('[data-component="dialog-v2"]')
+ .filter({ hasText: 'Delete workspace "feature-clean"?' })
+ const removed = page.waitForRequest(
+ (request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/experimental/worktree",
+ )
await confirmation.getByRole("button", { name: "Delete workspace" }).click()
- await expect.poll(() => removal).toEqual({ directory: root, body: { directory: cleanWorkspace } })
- expect(statusRequests).toBeGreaterThan(beforeRemoval)
+ const request = await removed
+ expect(new URL(request.url()).searchParams.get("directory")).toBe(root)
+ expect(request.postDataJSON()).toEqual({ directory: cleanWorkspace })
await expect(settings.getByText(cleanWorkspace, { exact: true })).toHaveCount(0)
- await expect(settings.getByText(workspace, { exact: true })).toBeVisible()
-})
-
-test("blocks deletion of the currently active workspace", async ({ page }) => {
- const draftID = "draft_workspace_active"
- let removed = false
- await mockOpenCodeServer(page, {
- directory: workspace,
- project,
- provider,
- sessions: [],
- pageMessages: () => ({ items: [] }),
- })
- await page.route("**/experimental/worktree**", async (route) => {
- if (route.request().method() !== "DELETE") return route.fallback()
- removed = true
- await route.fulfill({ status: 200, contentType: "application/json", body: "true" })
- })
- await init(page, { type: "draft", draftID, directory: workspace })
-
- await page.goto(`/new-session?draftId=${draftID}`)
- await page.getByRole("button", { name: /feature/ }).click()
- const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ })
- if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
- await page.getByRole("menuitem", { name: "View all" }).click()
- const settings = page.locator(".settings-v2-dialog")
- await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click()
- const confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' })
- await expect(confirmation).toContainText("active workspace")
- await expect(confirmation.getByRole("button", { name: "Delete workspace" })).toBeDisabled()
- expect(removed).toBe(false)
-})
-
-test("wraps the workspace toolbar for long project filters on mobile", async ({ page }) => {
- const draftID = "draft_workspace_mobile_filter"
- const other = {
- ...project,
- id: "proj_workspaces_other",
- name: "A second project with a deliberately long workspace filter label",
- worktree: "C:/OpenCode/AnotherWorkspaceProject",
- sandboxes: ["C:/OpenCode/worktree/another/feature-with-a-long-name"],
- }
- await mockOpenCodeServer(page, {
- directory: root,
- project,
- provider,
- sessions: [],
- pageMessages: () => ({ items: [] }),
- })
- await page.route("**/project**", async (route) => {
- if (new URL(route.request().url()).pathname !== "/project") return route.fallback()
- await route.fulfill({
- status: 200,
- contentType: "application/json",
- headers: { "access-control-allow-origin": "*" },
- body: JSON.stringify([project, other]),
- })
- })
- await init(page, { type: "draft", draftID, directory: root })
- await page.setViewportSize({ width: 375, height: 700 })
-
- await page.goto(`/new-session?draftId=${draftID}`)
- const dismissTabs = page.getByRole("button", { name: "Dismiss Tabs information" })
- if (await dismissTabs.isVisible()) await dismissTabs.click()
- await page.getByRole("button", { name: /local|new workspace/i }).click()
- const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ })
- if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
- await page.getByRole("menuitem", { name: "View all" }).click()
- const settings = page.locator(".settings-v2-dialog")
- await expect(settings.getByRole("button", { name: "All projects" })).toBeVisible()
- expect(await settings.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
-})
-
-test("bulk deletion snapshots inventory and skips dirty or unknown workspaces", async ({ page }) => {
- const draftID = "draft_workspace_delete_all"
- const unknown = `${workspace}-unknown`
- const clean = `${workspace}-clean`
- const later = `${workspace}-later`
- const inventory = { ...project, sandboxes: [workspace, unknown, clean] }
- const removals: string[] = []
- let release = () => {}
- const deleteGate = new Promise((resolve) => {
- release = resolve
- })
- const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server })
- await mockOpenCodeServer(page, {
- directory: root,
- project: inventory,
- provider,
- sessions: [],
- pageMessages: () => ({ items: [] }),
- })
- await page.route("**/vcs/status**", async (route) => {
- const directory = new URL(route.request().url()).searchParams.get("directory")
- if (directory === workspace) {
- await route.fulfill({
- status: 200,
- contentType: "application/json",
- headers: { "access-control-allow-origin": "*" },
- body: JSON.stringify([{ path: "dirty.ts", status: "modified" }]),
- })
- return
- }
- if (directory === unknown) {
- await route.fulfill({
- status: 500,
- contentType: "application/json",
- headers: { "access-control-allow-origin": "*" },
- body: JSON.stringify({ message: "status failed" }),
- })
- return
- }
- await route.fulfill({
- status: 200,
- contentType: "application/json",
- headers: { "access-control-allow-origin": "*" },
- body: "[]",
- })
- })
- await page.route("**/experimental/worktree**", async (route) => {
- if (route.request().method() === "OPTIONS") {
- await route.fulfill({
- status: 204,
- headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "DELETE" },
- })
- return
- }
- if (route.request().method() !== "DELETE") return route.fallback()
- const body = route.request().postDataJSON() as { directory: string }
- removals.push(body.directory)
- await deleteGate
- await route.fulfill({
- status: 200,
- contentType: "application/json",
- headers: { "access-control-allow-origin": "*" },
- body: "true",
- })
- })
- await init(page, { type: "draft", draftID, directory: root })
-
- await page.goto(`/new-session?draftId=${draftID}`)
- await transport.waitForConnection()
- await page.getByRole("button", { name: /local|new workspace/i }).click()
- const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ })
- if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
- await page.getByRole("menuitem", { name: "View all" }).click()
- const settings = page.locator(".settings-v2-dialog")
- await settings.getByRole("button", { name: "More options" }).click()
- await page.getByRole("menuitem", { name: "Delete all workspaces" }).click()
- const confirmation = page.locator('[data-dialog-layer="1"]')
- await expect(confirmation).toContainText("Delete all 3 workspaces?")
- await transport.send({
- directory: "global",
- payload: {
- id: "evt_workspace_added_after_confirmation",
- type: "project.updated",
- properties: { ...inventory, sandboxes: [...inventory.sandboxes, later] },
- },
- })
- await confirmation.getByRole("button", { name: "Delete all workspaces" }).click()
-
- await expect.poll(() => removals).toEqual([clean])
- const deleteButtons = settings.getByRole("button", { name: /Delete workspace/ })
- await expect.poll(() => deleteButtons.count()).toBeGreaterThan(0)
- expect(await deleteButtons.evaluateAll((buttons) => buttons.every((button) => button.hasAttribute("disabled")))).toBe(
- true,
- )
- release()
- await expect(settings.getByText(workspace, { exact: true })).toBeVisible()
- await expect(settings.getByText(unknown, { exact: true })).toBeVisible()
- await expect(settings.getByText(clean, { exact: true })).toHaveCount(0)
- await expect(settings.getByText(later, { exact: true })).toBeVisible()
-})
-
-test("applies the recovered new-workspace default without automatic cleanup", async ({ page }) => {
- const draftID = "draft_workspace_default"
- await mockOpenCodeServer(page, {
- directory: root,
- project,
- provider,
- sessions: [],
- pageMessages: () => ({ items: [] }),
- })
- await page.addInitScript(
- ({ draftID, root, server }) => {
- localStorage.setItem(
- "settings.v3",
- JSON.stringify({
- general: { newLayoutDesigns: true },
- workspaces: { defaultDestination: "new", lastUsed: {} },
- }),
- )
- localStorage.setItem(
- "opencode.global.dat:server",
- JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }),
- )
- localStorage.setItem(
- "opencode.window.browser.dat:tabs",
- JSON.stringify([{ type: "draft", draftID, server, directory: root }]),
- )
- },
- { draftID, root, server },
- )
-
- await page.goto(`/new-session?draftId=${draftID}`)
- await expect(page.getByRole("button", { name: /New workspace/ })).toBeVisible()
- await expect(page.getByText("from main", { exact: true })).toBeVisible()
})
test("submits the owning prompt after a new workspace becomes ready", async ({ page }) => {
@@ -474,7 +160,6 @@ test("submits the owning prompt after a new workspace becomes ready", async ({ p
version: "dev",
time: { created: 1, updated: 2 },
}
- let prompt: unknown
const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server })
await mockOpenCodeServer(page, {
directory: root,
@@ -525,7 +210,6 @@ test("submits the owning prompt after a new workspace becomes ready", async ({ p
})
return
}
- prompt = route.request().postDataJSON()
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*" },
@@ -543,142 +227,20 @@ test("submits the owning prompt after a new workspace becomes ready", async ({ p
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
await expect(lifecycle).toContainText("Creating workspace")
- for (const attempt of [1, 2, 3, 4, 5]) {
- await transport.send({
- directory: createdWorkspace,
- payload: {
- id: `evt_submit_ready_${attempt}`,
- type: "worktree.ready",
- properties: { name: "quick-contrast-fix" },
- },
- })
- await page.waitForTimeout(100)
- if (prompt) break
- }
- await expect.poll(() => prompt).not.toBeUndefined()
- await expect(lifecycle).toContainText("Workspace created")
-})
-
-test("shows neutral workspace identity and the ready-ish session summary panel", async ({ page }) => {
- const sessionID = "ses_workspace_summary"
- const messageID = "msg_workspace_summary"
- const session = {
- id: sessionID,
- slug: "workspace-summary",
- projectID: project.id,
- directory: workspace,
- title: "Workspace summary session",
- version: "dev",
- time: { created: 1, updated: 2 },
- }
- const vcsRequests: string[] = []
- page.on("request", (request) => {
- if (new URL(request.url()).pathname === "/api/vcs/diff") vcsRequests.push(request.url())
- })
- await mockOpenCodeServer(page, {
- protocol: "v2",
- directory: workspace,
- project,
- provider,
- sessions: [session],
- pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Implement workspace support", true)] }),
- vcsDiff: [diff],
- })
- await init(page, { type: "session", sessionId: sessionID })
-
- await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
- const timeline = page.locator("[data-workspace-session]")
- await expect(timeline).toBeVisible()
- await expect(timeline.locator(`[aria-label="${workspace}"]`)).toHaveAttribute("tabindex", "0")
- await expect(timeline.locator('[data-slot="session-title-child"]')).toHaveClass(/text-v2-text-text-base/)
- await expect(timeline.locator('[data-slot="user-message-text"]')).not.toHaveCSS(
- "background-color",
- "rgb(59, 92, 246)",
+ const sent = page.waitForRequest(
+ (request) =>
+ request.method() === "POST" && new URL(request.url()).pathname === `/session/${sessionID}/prompt_async`,
)
-
- const title = page.locator("[data-session-title]")
- await title.getByRole("button", { name: "Session details" }).click()
- const panel = page.locator('[data-component="session-summary-panel"]')
- await expect(panel).toBeVisible()
- await expect(panel).toContainText("workspace-project")
- await expect(panel).toContainText("feature")
- await expect(panel).toContainText("1 Changed file")
- await expect.poll(() => vcsRequests.length).toBeGreaterThan(0)
- const request = new URL(vcsRequests.at(-1)!)
- expect(request.searchParams.get("mode")).toBe("working")
- expect(request.searchParams.get("location[directory]")).toBe(workspace)
-})
-
-test("moves a changed local session to an existing workspace with an end-of-turn divider", async ({ page }) => {
- const sessionID = "ses_workspace_move_existing"
- const messageID = "msg_workspace_move_existing"
- const session = {
- id: sessionID,
- slug: "workspace-move-existing",
- projectID: project.id,
- directory: root,
- title: "Move this session",
- version: "dev",
- time: { created: 1, updated: 2 },
- }
- let move: unknown
- let releaseMove = () => {}
- const moveGate = new Promise((resolve) => {
- releaseMove = resolve
+ await transport.send({
+ directory: createdWorkspace,
+ payload: {
+ id: "evt_submit_ready",
+ type: "worktree.ready",
+ properties: { name: "quick-contrast-fix" },
+ },
})
- const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server })
- await mockOpenCodeServer(page, {
- directory: root,
- project,
- provider,
- sessions: [session],
- pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Move this work", true)] }),
- vcsDiff: [diff],
- })
- await page.route("**/experimental/control-plane/move-session", async (route) => {
- if (route.request().method() === "OPTIONS") {
- await route.fulfill({
- status: 204,
- headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
- })
- return
- }
- move = route.request().postDataJSON()
- await moveGate
- session.directory = workspace
- await route.fulfill({
- status: 200,
- contentType: "application/json",
- headers: { "access-control-allow-origin": "*" },
- body: "null",
- })
- })
- await init(page, { type: "session", sessionId: sessionID })
-
- await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
- await transport.waitForConnection()
- const inlineMove = page
- .locator('[data-component="session-turn-diffs-group"]')
- .getByRole("button", { name: "Move to workspace" })
- await expect(inlineMove).toBeVisible()
- await inlineMove.click()
- await page.getByRole("menuitem", { name: "Workspace", exact: true }).hover()
- await page.getByRole("menuitem", { name: "feature" }).click()
-
- await expect
- .poll(() => move)
- .toEqual({
- sessionID,
- destination: { directory: workspace },
- moveChanges: true,
- })
- await page.locator("[data-session-title]").getByRole("button", { name: "More options" }).click()
- await expect(page.getByRole("menuitem", { name: "Archive" })).toBeDisabled()
- await expect(page.getByRole("menuitem", { name: /Delete/ })).toBeDisabled()
- await page.keyboard.press("Escape")
- releaseMove()
- const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
- await expect(lifecycle).toContainText("Workspace set")
+ await sent
+ await expect(lifecycle).toContainText("Workspace created")
})
test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({
@@ -695,7 +257,6 @@ test("moves a changed local session through workspace creation without changing
version: "dev",
time: { created: 1, updated: 2 },
}
- let move: unknown
const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server })
await mockOpenCodeServer(page, {
directory: root,
@@ -729,7 +290,7 @@ test("moves a changed local session through workspace creation without changing
})
return
}
- move = route.request().postDataJSON()
+ session.directory = createdWorkspace
await route.fulfill({
status: 200,
contentType: "application/json",
@@ -749,25 +310,23 @@ test("moves a changed local session through workspace creation without changing
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
await expect(lifecycle).toContainText("Creating workspace")
- for (const attempt of [1, 2, 3, 4, 5]) {
- await transport.send({
- directory: createdWorkspace,
- payload: {
- id: `evt_worktree_ready_${attempt}`,
- type: "worktree.ready",
- properties: { name: "quick-contrast-fix" },
- },
- })
- await page.waitForTimeout(100)
- if (move) break
- }
- await expect
- .poll(() => move)
- .toEqual({
- sessionID,
- destination: { directory: createdWorkspace },
- moveChanges: true,
- })
+ const moved = page.waitForRequest(
+ (request) =>
+ request.method() === "POST" && new URL(request.url()).pathname === "/experimental/control-plane/move-session",
+ )
+ await transport.send({
+ directory: createdWorkspace,
+ payload: {
+ id: "evt_worktree_ready",
+ type: "worktree.ready",
+ properties: { name: "quick-contrast-fix" },
+ },
+ })
+ expect((await moved).postDataJSON()).toEqual({
+ sessionID,
+ destination: { directory: createdWorkspace },
+ moveChanges: true,
+ })
await transport.send({
directory: createdWorkspace,
payload: {
diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts
index a0a20a2e17f..a1e32e28daf 100644
--- a/packages/app/e2e/utils/sse-transport.ts
+++ b/packages/app/e2e/utils/sse-transport.ts
@@ -29,23 +29,38 @@ export type SseEventOptions = {
export type SseTransport = {
server: string
- waitForConnection(options?: { after?: number; timeout?: number }): Promise
- send(payload: T, options?: SseEventOptions): Promise
+ waitForConnection(options?: {
+ after?: number
+ timeout?: number
+ path?: SseConnectionRecord["path"]
+ }): Promise
+ send(payload: T, options?: SseEventOptions, path?: SseConnectionRecord["path"]): Promise
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise
heartbeat(options?: SseEventOptions): Promise
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise
close(): Promise
disconnect(message?: string): Promise
- error(message?: string): Promise
+ error(message?: string, path?: SseConnectionRecord["path"]): Promise
connections(): Promise
acknowledgements(): Promise
}
type BrowserCommand =
- | { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
+ | {
+ type: "send"
+ deliveries: { payload: T; options?: SseEventOptions }[]
+ burst: boolean
+ cuts?: number[]
+ path?: SseConnectionRecord["path"]
+ }
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
- | { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
+ | {
+ type: "end"
+ mode: "close" | "disconnect" | "error"
+ message?: string
+ path?: SseConnectionRecord["path"]
+ }
| { type: "connections" }
| { type: "acknowledgements" }
@@ -73,7 +88,8 @@ export async function installSseTransport(
let nextConnectionID = 0
let nextDeliveryID = 0
- const current = () => connections.findLast((connection) => connection.endedAt === undefined)
+ const current = (path?: SseConnectionRecord["path"]) =>
+ connections.findLast((connection) => connection.endedAt === undefined && (!path || connection.path === path))
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
const boundaries = [...new Set(cuts ?? [])]
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
@@ -125,8 +141,8 @@ export async function installSseTransport(
acknowledgements.push(acknowledgement)
return acknowledgement
}
- const end = (mode: "close" | "disconnect" | "error", message?: string) => {
- const connection = current()
+ const end = (mode: "close" | "disconnect" | "error", message?: string, path?: SseConnectionRecord["path"]) => {
+ const connection = current(path)
if (!connection) throw new Error("SSE transport has no active connection")
connection.endedAt = performance.now()
connection.endedBy = mode
@@ -146,8 +162,8 @@ export async function installSseTransport(
if (input.type === "connections")
return connections.map(({ controller: _controller, ...connection }) => connection)
if (input.type === "acknowledgements") return acknowledgements
- if (input.type === "end") return end(input.mode, input.message)
- const connection = current()
+ if (input.type === "end") return end(input.mode, input.message, input.path)
+ const connection = current(input.type === "send" ? input.path : undefined)
if (!connection) throw new Error("SSE transport has no active connection")
if (input.type === "raw") {
marker(input.marker)
@@ -248,12 +264,15 @@ export async function installSseTransport(
server,
async waitForConnection(input = {}) {
const connection = await page.waitForFunction(
- (after) => {
+ ({ after, path }) => {
const transport = (window as BrowserTransport).__testSseTransport
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
- return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
+ return connections?.findLast(
+ (connection) =>
+ connection.id > after && connection.endedAt === undefined && (!path || connection.path === path),
+ )
},
- input.after ?? 0,
+ { after: input.after ?? 0, path: input.path },
{ timeout: input.timeout },
)
let result: SseConnectionRecord | undefined
@@ -265,8 +284,8 @@ export async function installSseTransport(
if (!result) throw new Error("SSE transport connection disappeared while waiting")
return result
},
- send(payload, eventOptions) {
- return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
+ send(payload, eventOptions, path) {
+ return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, path })
},
burst(payloads, eventOptions = []) {
return command({
@@ -304,8 +323,8 @@ export async function installSseTransport(
disconnect(message) {
return command({ type: "end", mode: "disconnect", message })
},
- error(message) {
- return command({ type: "end", mode: "error", message })
+ error(message, path) {
+ return command({ type: "end", mode: "error", message, path })
},
connections() {
return command({ type: "connections" })
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 0c950871a33..996f912a73b 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -1,6 +1,7 @@
import "@/index.css"
import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context"
+import type { UiI18n } from "@opencode-ai/ui/context/i18n"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { File } from "@opencode-ai/session-ui/file"
@@ -235,7 +236,13 @@ function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return (
{props.children}
diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts
index 3a058a443be..3cb9f845cb0 100644
--- a/packages/app/src/components/prompt-input/submit.test.ts
+++ b/packages/app/src/components/prompt-input/submit.test.ts
@@ -1,21 +1,18 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { createStore } from "solid-js/store"
import type { Prompt, PromptStore } from "@/context/prompt"
-import type { ModelSelection } from "@/context/local"
import { Worktree } from "@/utils/worktree"
import { WorkspaceOperation } from "@/utils/workspace-operation"
import { ServerScope } from "@/utils/server-scope"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
-const createdClients: string[] = []
const createdSessions: string[] = []
-const sessionCreateInputs: Array<{
+type SessionCreateInput = {
agent?: string
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
-}> = []
-const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
+}
const optimistic: Array<{
directory?: string
sessionID?: string
@@ -25,17 +22,13 @@ const optimistic: Array<{
variant?: string
}
}> = []
-const optimisticSeeded: boolean[] = []
const storedSessions: Record> = {}
-const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShellDirectories: string[] = []
-const syncedDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
const sentCommands: unknown[] = []
-const sentCommandDirectories: string[] = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const syncedServers: string[] = []
const optimisticServers: string[] = []
@@ -46,9 +39,7 @@ let params: { id?: string } = {}
let search: { draftId?: string } = {}
let selected = "/repo/worktree-a"
let variant: string | undefined
-let permissionServer = "server-a"
let createSessionGate: Promise | undefined
-let sessionFailure: Error | undefined
let createWorktreeGate: Promise | undefined
let worktreeFailure: Error | undefined
let worktreeHung = false
@@ -94,17 +85,14 @@ const prompt = {
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
-const clientFor = (directory: string, track = true) => {
- if (track) createdClients.push(directory)
+const clientFor = (directory: string) => {
return {
api: {
session: {
- create: async (input: (typeof sessionCreateInputs)[number]) => {
+ create: async (input: SessionCreateInput) => {
await createSessionGate
- if (sessionFailure) throw sessionFailure
const location = input.location?.directory ?? directory
createdSessions.push(location)
- sessionCreateInputs.push(input)
return {
id: `session-${createdSessions.length}`,
projectID: "project",
@@ -124,7 +112,6 @@ const clientFor = (directory: string, track = true) => {
},
command: async (input: unknown) => {
sentCommands.push(input)
- sentCommandDirectories.push(directory)
},
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
@@ -163,7 +150,6 @@ beforeAll(async () => {
mock.module("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: (input: { directory: string }) => {
- createdClients.push(input.directory)
return clientFor(input.directory)
},
}))
@@ -188,20 +174,13 @@ beforeAll(async () => {
current: () => ({ name: "agent" }),
},
session: {
- promote(directory: string, sessionID: string) {
- promoted.push({ directory, sessionID })
- },
+ promote: () => undefined,
},
}),
}))
mock.module("@/context/permission", () => {
- const state = (server: string) => ({
- enableAutoAccept(sessionID: string, directory: string) {
- enabledAutoAccept.push({ server, sessionID, directory })
- },
- })
- return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
+ return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
})
mock.module("@/context/server", () => ({
@@ -240,7 +219,7 @@ beforeAll(async () => {
client: rootClient,
api: rootClient.api,
url: "http://localhost:4096",
- createApi: (directory: string) => clientFor(directory, false).api,
+ createApi: (directory: string) => clientFor(directory).api,
createClient(opts: any) {
return clientFor(opts.directory)
},
@@ -262,11 +241,6 @@ beforeAll(async () => {
}) => {
optimisticServers.push(server)
optimistic.push(value)
- optimisticSeeded.push(
- !!value.directory &&
- !!value.sessionID &&
- !!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
- )
},
remove: () => undefined,
},
@@ -290,7 +264,6 @@ beforeAll(async () => {
},
child: (directory: string) => {
syncedServers.push(server)
- syncedDirectories.push(directory)
storedSessions[directory] ??= []
return [
{ session: storedSessions[directory] },
@@ -328,17 +301,11 @@ beforeAll(async () => {
})
beforeEach(() => {
- createdClients.length = 0
createdSessions.length = 0
- sessionCreateInputs.length = 0
- enabledAutoAccept.length = 0
optimistic.length = 0
- optimisticSeeded.length = 0
- promoted.length = 0
promotedDrafts.length = 0
updatedDrafts.length = 0
sentCommands.length = 0
- sentCommandDirectories.length = 0
sentPrompts.length = 0
promptInputs.length = 0
syncedServers.length = 0
@@ -348,10 +315,8 @@ beforeEach(() => {
search = {}
sentShell.length = 0
sentShellDirectories.length = 0
- syncedDirectories.length = 0
selected = "/repo/worktree-a"
variant = undefined
- permissionServer = "server-a"
activeSDK = "server-a"
activeServerSync = "server-a"
activeDirectorySync = "server-a"
@@ -360,7 +325,6 @@ beforeEach(() => {
worktreeDirectory = `/repo/new-${++worktreeID}`
createSessionGate = undefined
serverSessionSyncs = 0
- sessionFailure = undefined
createWorktreeGate = undefined
worktreeFailure = undefined
worktreeHung = false
@@ -369,6 +333,29 @@ beforeEach(() => {
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
})
+const event = { preventDefault: () => undefined } as unknown as Event
+const makeSubmit = (overrides: Partial[0]> = {}) =>
+ createPromptSubmit({
+ prompt,
+ info: () => undefined,
+ imageAttachments: () => [],
+ commentCount: () => 0,
+ autoAccept: () => false,
+ mode: () => "normal",
+ working: () => false,
+ editor: () => undefined,
+ queueScroll: () => undefined,
+ promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+ addToHistory: () => undefined,
+ resetHistoryNavigation: () => undefined,
+ setMode: () => undefined,
+ setPopover: () => undefined,
+ newSessionWorktree: () => selected,
+ onNewSessionWorktreeReset: () => undefined,
+ onSubmit: () => undefined,
+ ...overrides,
+ })
+
describe("prompt submit worktree selection", () => {
test("admits only one concurrent new-workspace submission", async () => {
selected = "create"
@@ -376,27 +363,8 @@ describe("prompt submit worktree selection", () => {
createWorktreeGate = new Promise((resolve) => {
release = resolve
})
- const submit = createPromptSubmit({
- prompt,
- info: () => undefined,
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
- mode: () => "normal",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- newSessionWorktree: () => selected,
- onNewSessionWorktreeReset: () => undefined,
- onSubmit: () => undefined,
- })
+ const submit = makeSubmit()
- const event = { preventDefault: () => undefined } as unknown as Event
const first = submit.handleSubmit(event)
const duplicate = submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
@@ -412,70 +380,15 @@ describe("prompt submit worktree selection", () => {
expect(sentPrompts).toEqual([worktreeDirectory])
})
- test("allows retry after new-workspace submission fails", async () => {
- selected = "create"
- worktreeFailure = new Error("create failed")
- const submit = createPromptSubmit({
- prompt,
- info: () => undefined,
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
- mode: () => "normal",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- newSessionWorktree: () => selected,
- onNewSessionWorktreeReset: () => undefined,
- onSubmit: () => undefined,
- })
-
- const event = { preventDefault: () => undefined } as unknown as Event
- await submit.handleSubmit(event)
- expect(worktreeCreates).toBe(1)
- expect(createdSessions).toEqual([])
-
- worktreeFailure = undefined
- await submit.handleSubmit(event)
- Worktree.ready(ServerScope.local, worktreeDirectory)
- await settle()
-
- expect(worktreeCreates).toBe(2)
- expect(createdSessions).toEqual([worktreeDirectory])
- expect(sentPrompts).toEqual([worktreeDirectory])
- })
-
test("aborts a hung new-workspace request and allows retry", async () => {
selected = "create"
worktreeHung = true
let resets = 0
- const submit = createPromptSubmit({
- prompt,
- info: () => undefined,
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
- mode: () => "normal",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- newSessionWorktree: () => selected,
+ const submit = makeSubmit({
onNewSessionWorktreeReset: () => resets++,
- onSubmit: () => undefined,
worktreeRequestTimeoutMs: 1,
})
- const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
@@ -495,160 +408,6 @@ describe("prompt submit worktree selection", () => {
expect(resets).toBe(1)
})
- test("retains workspace selection after session creation failure and clears it after retry", async () => {
- sessionFailure = new Error("session create failed")
- let resets = 0
- const submit = createPromptSubmit({
- prompt,
- info: () => undefined,
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
- mode: () => "normal",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- newSessionWorktree: () => selected,
- onNewSessionWorktreeReset: () => {
- resets++
- selected = "main"
- },
- onSubmit: () => undefined,
- })
-
- const event = { preventDefault: () => undefined } as unknown as Event
- await submit.handleSubmit(event)
- expect(resets).toBe(0)
- expect(selected).toBe("/repo/worktree-a")
-
- sessionFailure = undefined
- await submit.handleSubmit(event)
- await settle()
-
- expect(resets).toBe(1)
- expect(selected).toBe("main")
- })
-
- test("reads the latest worktree accessor value per submit", async () => {
- const submit = createPromptSubmit({
- prompt,
- info: () => undefined,
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
- mode: () => "shell",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- newSessionWorktree: () => selected,
- onNewSessionWorktreeReset: () => undefined,
- onSubmit: () => undefined,
- })
-
- const event = { preventDefault: () => undefined } as unknown as Event
-
- await submit.handleSubmit(event)
- selected = "/repo/worktree-b"
- await submit.handleSubmit(event)
-
- expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
- expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
- expect(sessionCreateInputs).toEqual([
- {
- agent: "agent",
- model: { id: "model", providerID: "provider", variant: undefined },
- location: { directory: "/repo/worktree-a" },
- },
- {
- agent: "agent",
- model: { id: "model", providerID: "provider", variant: undefined },
- location: { directory: "/repo/worktree-b" },
- },
- ])
- expect(sentShell).toEqual([
- expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
- expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
- ])
- expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
- expect(serverSessionSyncs).toBe(0)
- expect(promoted).toEqual([
- { directory: "/repo/worktree-a", sessionID: "session-1" },
- { directory: "/repo/worktree-b", sessionID: "session-2" },
- ])
- expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
- })
-
- test("applies auto-accept to newly created sessions", async () => {
- const submit = createPromptSubmit({
- prompt,
- info: () => undefined,
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => true,
- mode: () => "shell",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- newSessionWorktree: () => selected,
- onNewSessionWorktreeReset: () => undefined,
- onSubmit: () => undefined,
- })
-
- const event = { preventDefault: () => undefined } as unknown as Event
-
- await submit.handleSubmit(event)
-
- expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
- })
-
- test("keeps auto-accept bound to the submission server", async () => {
- let release = () => {}
- createSessionGate = new Promise((resolve) => {
- release = resolve
- })
- const submit = createPromptSubmit({
- prompt,
- info: () => undefined,
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => true,
- mode: () => "shell",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- newSessionWorktree: () => selected,
- onNewSessionWorktreeReset: () => undefined,
- onSubmit: () => undefined,
- })
-
- const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
- permissionServer = "server-b"
- release()
- await result
-
- expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
- })
-
test("keeps async submission effects bound to the initiating context", async () => {
search = { draftId: "draft-1" }
draftServers["draft-1"] = "project-server-a"
@@ -658,27 +417,11 @@ describe("prompt submit worktree selection", () => {
release = resolve
})
let submitted = 0
- const submit = createPromptSubmit({
- prompt,
- info: () => undefined,
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
- mode: () => "normal",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- newSessionWorktree: () => selected,
- onNewSessionWorktreeReset: () => undefined,
+ const submit = makeSubmit({
onSubmit: () => submitted++,
})
- const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+ const result = submit.handleSubmit(event)
activeSDK = "server-b"
activeServerSync = "server-b"
activeDirectorySync = "server-b"
@@ -697,57 +440,14 @@ describe("prompt submit worktree selection", () => {
expect(submitted).toBe(0)
})
- test("promotes drafts using the selected project's server", async () => {
- search = { draftId: "draft-1" }
- const submit = createPromptSubmit({
- prompt,
- info: () => undefined,
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
- mode: () => "normal",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- newSessionWorktree: () => selected,
- onNewSessionWorktreeReset: () => undefined,
- onSubmit: () => undefined,
- })
-
- await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
-
- expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
- })
-
test("includes the selected variant on optimistic prompts", async () => {
params.id = "session-1"
variant = "high"
- const submit = createPromptSubmit({
- prompt,
+ const submit = makeSubmit({
info: () => ({ id: "session-1" }),
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
- mode: () => "normal",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- onSubmit: () => undefined,
})
- const event = { preventDefault: () => undefined } as unknown as Event
-
await submit.handleSubmit(event)
await Bun.sleep(0)
@@ -777,24 +477,11 @@ describe("prompt submit worktree selection", () => {
commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
- const submit = createPromptSubmit({
- prompt,
+ const submit = makeSubmit({
info: () => ({ id: "session-1" }),
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
- mode: () => "normal",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
})
- await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+ await submit.handleSubmit(event)
await settle()
expect(sentCommands).toEqual([
@@ -811,92 +498,13 @@ describe("prompt submit worktree selection", () => {
expect(serverSessionSyncs).toBe(0)
})
- test("uses an injected model selection", async () => {
- params = { id: "session-1" }
- const model = {
- current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
- variant: { current: () => "draft-variant" },
- } as unknown as ModelSelection
- const submit = createPromptSubmit({
- prompt,
- info: () => ({ id: "session-1" }),
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
- mode: () => "normal",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- model,
- })
-
- await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
-
- expect(optimistic[0]).toMatchObject({
- message: {
- model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
- },
- })
- })
-
- test("seeds new sessions before optimistic prompts are added", async () => {
- const submit = createPromptSubmit({
- prompt,
- info: () => undefined,
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
- mode: () => "normal",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- newSessionWorktree: () => selected,
- onNewSessionWorktreeReset: () => undefined,
- onSubmit: () => undefined,
- })
-
- const event = { preventDefault: () => undefined } as unknown as Event
-
- await submit.handleSubmit(event)
-
- expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
- expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
- expect(optimisticSeeded).toEqual([true])
- })
-
test("waits for a new workspace before sending an initial shell", async () => {
selected = "create"
- const submit = createPromptSubmit({
- prompt,
- info: () => undefined,
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
+ const submit = makeSubmit({
mode: () => "shell",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- newSessionWorktree: () => selected,
- onNewSessionWorktreeReset: () => undefined,
- onSubmit: () => undefined,
})
- await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+ await submit.handleSubmit(event)
expect(sentShell).toEqual([])
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
@@ -913,71 +521,11 @@ describe("prompt submit worktree selection", () => {
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
})
- test("waits for a new workspace before sending an initial custom command", async () => {
- selected = "create"
- commands = [{ name: "deploy" }]
- promptValue = [{ type: "text", content: "/deploy now", start: 0, end: 11 }]
- const submit = createPromptSubmit({
- prompt,
- info: () => undefined,
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
- mode: () => "normal",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- newSessionWorktree: () => selected,
- onNewSessionWorktreeReset: () => undefined,
- onSubmit: () => undefined,
- })
-
- await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
- expect(sentCommands).toEqual([])
-
- Worktree.ready(ServerScope.local, worktreeDirectory)
- await settle()
-
- expect(sentCommands).toHaveLength(1)
- expect(sentCommands[0]).toMatchObject({
- sessionID: "session-1",
- command: "deploy",
- arguments: "now",
- agent: "agent",
- model: { id: "model", providerID: "provider" },
- files: [],
- })
- expect(sentCommandDirectories).toEqual([worktreeDirectory])
- })
-
test("settles a pending workspace operation when the initial prompt is aborted", async () => {
selected = "create"
- const submit = createPromptSubmit({
- prompt,
- info: () => undefined,
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
- mode: () => "normal",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- newSessionWorktree: () => selected,
- onNewSessionWorktreeReset: () => undefined,
- onSubmit: () => undefined,
- })
+ const submit = makeSubmit()
- await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+ await submit.handleSubmit(event)
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
params = { id: "session-1" }
@@ -987,40 +535,6 @@ describe("prompt submit worktree selection", () => {
expect(sentPrompts).toEqual([])
expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({
status: "failed",
- message: "aborted",
- })
- })
-
- test("settles a pending workspace operation when preparation fails", async () => {
- selected = "create"
- const submit = createPromptSubmit({
- prompt,
- info: () => undefined,
- imageAttachments: () => [],
- commentCount: () => 0,
- autoAccept: () => false,
- mode: () => "shell",
- working: () => false,
- editor: () => undefined,
- queueScroll: () => undefined,
- promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
- addToHistory: () => undefined,
- resetHistoryNavigation: () => undefined,
- setMode: () => undefined,
- setPopover: () => undefined,
- newSessionWorktree: () => selected,
- onNewSessionWorktreeReset: () => undefined,
- onSubmit: () => undefined,
- })
-
- await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
- Worktree.failed(ServerScope.local, worktreeDirectory, "bootstrap failed")
- await settle()
-
- expect(sentShell).toEqual([])
- expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({
- status: "failed",
- message: "bootstrap failed",
})
})
})
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index 4f59c88b037..4436be1aa1e 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -284,7 +284,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (queued) {
queued.abort.abort()
queued.cleanup()
- WorkspaceOperation.fail(queued.scope, queued.sessionID, "aborted")
+ WorkspaceOperation.fail(queued.scope, queued.sessionID)
pending.delete(key)
return Promise.resolve()
}
@@ -543,7 +543,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID)
if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id)
- if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id, worktree.message)
+ if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id)
}
const waitForWorktree = async (cleanup: VoidFunction) => {
@@ -554,7 +554,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return true
}
if (worktree.status === "failed") {
- WorkspaceOperation.fail(submissionScope, session.id, worktree.message)
+ WorkspaceOperation.fail(submissionScope, session.id)
throw new Error(worktree.message)
}
@@ -610,7 +610,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
})
if (controller.signal.aborted) return false
if (result.status === "failed") {
- WorkspaceOperation.fail(submissionScope, session.id, result.message)
+ WorkspaceOperation.fail(submissionScope, session.id)
throw new Error(result.message)
}
WorkspaceOperation.complete(submissionScope, session.id)
diff --git a/packages/app/src/components/session-workspace-menu.tsx b/packages/app/src/components/session-workspace-menu.tsx
index 1ac2ad20cb1..25ec19df27a 100644
--- a/packages/app/src/components/session-workspace-menu.tsx
+++ b/packages/app/src/components/session-workspace-menu.tsx
@@ -45,9 +45,8 @@ export function SessionWorkspaceMenu(props: {
workspaceDirectories(props.project).filter((workspace) => pathKey(workspace) !== pathKey(props.directory))
const fail = (scope: ServerScope, sessionID: string, message: string) => {
- setStore("selected", undefined)
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
- WorkspaceOperation.fail(scope, sessionID, message)
+ WorkspaceOperation.fail(scope, sessionID)
showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message })
}
const move = async (selection: "create" | string) => {
@@ -72,10 +71,7 @@ export function SessionWorkspaceMenu(props: {
if (!destination) return
WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID)
- if (sync.session.data.session_working(sessionID)) {
- fail(scope, sessionID, language.t("workspace.move.failed"))
- return
- }
+ if (sync.session.data.session_working(sessionID)) throw new Error(language.t("workspace.move.failed"))
await workspaceRequestWithTimeout(
(signal) =>
sdk.client.experimental.controlPlane.moveSession(
@@ -89,26 +85,17 @@ export function SessionWorkspaceMenu(props: {
language.t("workspace.move.failed"),
WORKSPACE_PREPARATION_TIMEOUT_MS,
)
- .then(async () => {
- for (const attempt of Array.from({ length: 20 }, (_, index) => index)) {
- const session = await workspaceRequestWithTimeout(
- (signal) => sync.session.resolve(sessionID, { force: true, signal }),
- language.t("workspace.move.failed"),
- WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
- ).catch(() => undefined)
- if (session && pathKey(session.directory) === pathKey(destination)) {
- WorkspaceOperation.complete(scope, sessionID, destination)
- sync.reindexSession(sessionID, source)
- return
- }
- if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
- await new Promise((resolve) => setTimeout(resolve, Math.min(250 + attempt * 50, 1_000)))
- }
- fail(scope, sessionID, language.t("workspace.move.failed"))
- })
- .catch((error) =>
- fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed")),
- )
+ const session = await workspaceRequestWithTimeout(
+ (signal) => sync.session.resolve(sessionID, { force: true, signal }),
+ language.t("workspace.move.failed"),
+ WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
+ )
+ if (!session || pathKey(session.directory) !== pathKey(destination))
+ throw new Error(language.t("workspace.move.failed"))
+ WorkspaceOperation.complete(scope, sessionID, destination)
+ sync.reindexSession(sessionID, source)
+ } catch (error) {
+ fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed"))
} finally {
setStore("selected", undefined)
}
diff --git a/packages/app/src/components/settings-v2/workspaces.tsx b/packages/app/src/components/settings-v2/workspaces.tsx
index d1771ed87b5..92bca05e49a 100644
--- a/packages/app/src/components/settings-v2/workspaces.tsx
+++ b/packages/app/src/components/settings-v2/workspaces.tsx
@@ -29,9 +29,7 @@ import {
inspectWorkspaceDeletion,
mergeWorkspaceSessionInventory,
removeWorkspacesSequentially,
- runWorkspaceDeleteTransaction,
sessionsForWorkspace,
- type WorkspaceDeleteTransaction,
type WorkspaceDeleteInspection,
workspaceInventory,
} from "@/utils/workspace"
@@ -53,9 +51,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
const platform = usePlatform()
const [store, setStore] = createStore({
project: "all",
- deleting: undefined as string | undefined,
- deletingAll: false,
- transaction: undefined as WorkspaceDeleteTransaction,
+ transaction: undefined as "confirm" | "running" | undefined,
})
const workspaces = createMemo(() => workspaceInventory(serverSync().data.project))
@@ -97,7 +93,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
if (sessionQuery.isPending) return language.t("session.messages.loading")
if (sessionQuery.isError) return language.t("common.requestFailed")
const count = workspaceSessions(workspace).length
- return language.t(count === 1 ? "settings.workspaces.sessions.one" : "settings.workspaces.sessions.other", {
+ return language.plural("settings.workspaces.sessions", count, {
count,
project: projectName(workspace.project),
})
@@ -113,30 +109,23 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
}
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
- const [status, sessions] = await Promise.all([
- Promise.all([
- context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
- context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
- ])
- .then(([working, branch]) =>
- working.data.length > 0 || branch.data.length > 0 ? ("dirty" as const) : ("clean" as const),
- )
- .catch(() => "unknown" as const),
- loadSessions(context).catch(() => undefined),
+ const [working, branch, sessions] = await Promise.all([
+ context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
+ context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
+ loadSessions(context),
])
const result = inspectWorkspaceDeletion({
workspace: workspace.directory,
activeDirectory: context.activeDirectory,
- sessions: sessions ?? [],
- status: sessions ? status : "unknown",
+ sessions,
+ status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
})
- return { result, sessions: sessions ?? [] }
+ return { result, sessions }
}
const inspectionMessage = (result: WorkspaceDeleteInspection) => {
if (result === "active") return language.t("settings.workspaces.delete.blocked.active")
if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked")
if (result === "dirty") return language.t("workspace.status.dirty")
- if (result === "unknown") return language.t("workspace.status.error")
return language.t("workspace.status.clean")
}
const blocked = (result: WorkspaceDeleteInspection) => {
@@ -147,12 +136,10 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
})
}
- const remove = async (workspace: Workspace, bulk = false, context = captureDeleteContext()) => {
- if (!bulk) setStore("deleting", pathKey(workspace.directory))
+ const remove = async (workspace: Workspace, allowDirty = false, context = captureDeleteContext()) => {
const preflight = await inspect(workspace, context)
- if (preflight.result !== "safe" && (bulk || preflight.result !== "dirty")) {
+ if (preflight.result !== "safe" && (!allowDirty || preflight.result !== "dirty")) {
blocked(preflight.result)
- if (!bulk) setStore("deleting", undefined)
return
}
const removed = await context.sdk.client.worktree
@@ -169,7 +156,6 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
})
return false
})
- if (!bulk) setStore("deleting", undefined)
if (!removed) return
tabs.store.forEach((tab) => {
if (tab.type !== "draft" || tab.server !== context.server) return
@@ -203,19 +189,20 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
const releaseConfirmation = () => {
if (store.transaction === "confirm") setStore("transaction", undefined)
}
- let transactionID = 0
const transact = async (task: () => Promise) => {
- await runWorkspaceDeleteTransaction({
- token: ++transactionID,
- set: (update) => setStore("transaction", update),
- task: async () => {
- try {
- await task()
- } finally {
- setStore({ deleting: undefined, deletingAll: false })
- }
- },
- })
+ if (store.transaction !== "confirm") return
+ setStore("transaction", "running")
+ try {
+ await task()
+ } catch (error) {
+ showToast({
+ variant: "error",
+ title: language.t("workspace.delete.failed.title"),
+ description: error instanceof Error ? error.message : language.t("common.requestFailed"),
+ })
+ } finally {
+ setStore("transaction", undefined)
+ }
}
const confirmDelete = (workspace: Workspace) => {
if (store.transaction) return
@@ -230,15 +217,14 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
inspectionID={current}
inspect={() => inspect(workspace, context)}
inspectionMessage={inspectionMessage}
- onDelete={() => transact(() => remove(workspace, false, context))}
+ onDelete={() => transact(() => remove(workspace, true, context))}
/>
),
releaseConfirmation,
)
}
const removeAll = async (inventory: Workspace[], context: ReturnType) => {
- setStore("deletingAll", true)
- await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, true, context))
+ await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
}
const confirmDeleteAll = () => {
if (store.transaction) return
@@ -267,12 +253,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr