From 0d8709a72e106483277fbefc50736d5f27e59593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=96=8E=F0=9D=96=9A=F0=9D=96=91=F0=9D=96=8E?= =?UTF-8?q?=F0=9D=96=8E=F0=9D=96=86?= Date: Thu, 3 Sep 2026 15:17:28 +0300 Subject: [PATCH 1/2] fix(worktrees): remove worktrees in the background (#3319) * refactor(worktrees): fetch source once during creation * fix(worktrees): remove worktrees in background * fix(worktrees): show background removal progress * fix(worktrees): name the worktree in removal toasts --- .../src/apps/MobileDeleteWorktreeDialog.tsx | 64 +++----- .../sections/behavior/BehaviorPage.tsx | 42 +----- .../src/components/session/SessionDialogs.tsx | 122 +++++++-------- packages/ui/src/lib/api/types.ts | 1 + packages/ui/src/lib/desktop.ts | 1 - .../ui/src/lib/i18n/messages/de.settings.ts | 4 - packages/ui/src/lib/i18n/messages/de.ts | 5 +- .../ui/src/lib/i18n/messages/en.settings.ts | 4 - packages/ui/src/lib/i18n/messages/en.ts | 5 +- .../ui/src/lib/i18n/messages/es.settings.ts | 4 - packages/ui/src/lib/i18n/messages/es.ts | 5 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 4 - packages/ui/src/lib/i18n/messages/fr.ts | 5 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 4 - packages/ui/src/lib/i18n/messages/ja.ts | 5 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 4 - packages/ui/src/lib/i18n/messages/ko.ts | 5 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 4 - packages/ui/src/lib/i18n/messages/pl.ts | 5 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 4 - packages/ui/src/lib/i18n/messages/pt-BR.ts | 5 +- .../ui/src/lib/i18n/messages/tr.settings.ts | 4 - packages/ui/src/lib/i18n/messages/tr.ts | 5 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 4 - packages/ui/src/lib/i18n/messages/uk.ts | 5 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 4 - packages/ui/src/lib/i18n/messages/zh-CN.ts | 5 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 4 - packages/ui/src/lib/i18n/messages/zh-TW.ts | 5 +- packages/ui/src/lib/persistence.ts | 3 - packages/ui/src/lib/settings/search.ts | 6 - .../src/lib/worktrees/worktreeCreate.test.ts | 142 +++--------------- .../ui/src/lib/worktrees/worktreeCreate.ts | 31 +--- .../src/lib/worktrees/worktreeManager.test.ts | 24 +++ .../ui/src/lib/worktrees/worktreeManager.ts | 11 ++ packages/ui/src/stores/useConfigStore.ts | 12 -- packages/vscode/src/DOCUMENTATION.md | 1 + packages/vscode/src/gitService.ts | 96 ++++++++---- ...gitService.worktree-fetch-fallback.test.js | 6 +- packages/web/server/lib/git/DOCUMENTATION.md | 3 +- packages/web/server/lib/git/service.js | 91 +++++++---- packages/web/server/lib/git/service.test.js | 6 +- .../server/lib/opencode/settings-helpers.js | 3 - 43 files changed, 326 insertions(+), 447 deletions(-) diff --git a/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx b/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx index a39ccca5..8a0c454d 100644 --- a/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx +++ b/packages/ui/src/apps/MobileDeleteWorktreeDialog.tsx @@ -7,7 +7,7 @@ import { toast } from '@/components/ui'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { getWorktreeStatus } from '@/lib/worktrees/worktreeStatus'; -import { removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager'; +import { getWorktreeDisplayName, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -91,9 +91,24 @@ export const MobileDeleteWorktreeDialog: React.FC { + const removeWorktreeInBackground = React.useCallback((target: WorktreeMetadata, sessionIds: string[]) => { + const name = getWorktreeDisplayName(target); + const toastId = toast.loading(t('sessions.sidebar.sessionDialogs.worktree.removingTitle', { name })); void (async () => { try { + if (sessionIds.length > 0) { + const { failedIds } = await archiveSessions(sessionIds); + if (failedIds.length > 0) { + toast.error( + failedIds.length === 1 + ? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length }) + : t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }), + { id: toastId, description: t('sessions.sidebar.dialogs.deleteResult.tryAgain') }, + ); + return; + } + } + await removeProjectWorktree(project, target, { deleteRemoteBranch: hasBranch && deleteRemoteBranch, deleteLocalBranch: hasBranch && deleteLocalBranch, @@ -104,7 +119,8 @@ export const MobileDeleteWorktreeDialog: React.FC { + const handleConfirm = () => { if (!worktree || isProcessing) return; setIsProcessing(true); - try { - if (linkedSessions.length > 0) { - const { archivedIds, failedIds } = await archiveSessions(linkedSessions.map((session) => session.id)); - if (failedIds.length > 0) { - if (archivedIds.length > 0) { - toast.success( - archivedIds.length === 1 - ? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length }) - : t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }), - ); - } - toast.error( - failedIds.length === 1 - ? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length }) - : t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }), - { description: t('sessions.sidebar.dialogs.deleteResult.tryAgain') }, - ); - setIsProcessing(false); - return; - } - } - removeWorktreeInBackground(worktree); - onClose(); - } catch (error) { - toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), { - description: error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain'), - }); - setIsProcessing(false); - } finally { - if (!open) { - setIsProcessing(false); - } - } + removeWorktreeInBackground(worktree, linkedSessions.map((session) => session.id)); + onClose(); }; if (!worktree) return null; diff --git a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx index b5d388f7..758c954c 100644 --- a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx +++ b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx @@ -20,7 +20,6 @@ import { } from '@/lib/responseStyle'; import type { DesktopSettings } from '@/lib/desktop'; import { runtimeFetch } from '@/lib/runtime-fetch'; -import { useConfigStore } from '@/stores/useConfigStore'; import { noteDeferredRestartFromPayload, recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; import { @@ -47,7 +46,6 @@ type ResponseStyleValue = ResponseStylePreset | 'custom'; type BehaviorSettingsState = { prompt: string; optimizeSystemPrompt: boolean; - worktreeFetchSource: boolean; responseStyleEnabled: boolean; responseStylePreset: ResponseStyleValue; responseStyleCustomInstructions: string; @@ -56,7 +54,6 @@ type BehaviorSettingsState = { const DEFAULT_BEHAVIOR_SETTINGS: BehaviorSettingsState = { prompt: '', optimizeSystemPrompt: false, - worktreeFetchSource: true, responseStyleEnabled: false, responseStylePreset: 'concise', responseStyleCustomInstructions: '', @@ -106,10 +103,8 @@ const saveBehaviorSetting = async (settings: Partial, fallbackE export const BehaviorPage: React.FC = () => { const { t } = useI18n(); const isVSCode = useIsVSCodeRuntime(); - const setSettingsWorktreeFetchSource = useConfigStore((state) => state.setSettingsWorktreeFetchSource); const [prompt, setPrompt] = React.useState(''); const [optimizeSystemPrompt, setOptimizeSystemPrompt] = React.useState(false); - const [worktreeFetchSource, setWorktreeFetchSource] = React.useState(DEFAULT_BEHAVIOR_SETTINGS.worktreeFetchSource); const [responseStyleEnabled, setResponseStyleEnabled] = React.useState(DEFAULT_BEHAVIOR_SETTINGS.responseStyleEnabled); const [responseStylePreset, setResponseStylePreset] = React.useState(DEFAULT_BEHAVIOR_SETTINGS.responseStylePreset); const [responseStyleCustomInstructions, setResponseStyleCustomInstructions] = React.useState(DEFAULT_BEHAVIOR_SETTINGS.responseStyleCustomInstructions); @@ -148,16 +143,12 @@ export const BehaviorPage: React.FC = () => { nextSettings = { ...nextSettings, optimizeSystemPrompt: data.optimizeSystemPrompt === true, - worktreeFetchSource: data.worktreeFetchSource !== false, responseStyleEnabled: data.responseStyleEnabled === true, responseStylePreset: sanitizeResponseStylePreset(data.responseStylePreset), responseStyleCustomInstructions: typeof data.responseStyleCustomInstructions === 'string' ? data.responseStyleCustomInstructions : '', }; - if (typeof data.worktreeFetchSource === 'boolean') { - setSettingsWorktreeFetchSource(data.worktreeFetchSource); - } if (typeof data.globalBehaviorPrompt === 'string') { nextSettings = { ...nextSettings, prompt: data.globalBehaviorPrompt }; } @@ -173,7 +164,6 @@ export const BehaviorPage: React.FC = () => { setPrompt(nextSettings.prompt); setOptimizeSystemPrompt(nextSettings.optimizeSystemPrompt); setInitialOptimizeSystemPrompt(nextSettings.optimizeSystemPrompt); - setWorktreeFetchSource(nextSettings.worktreeFetchSource); setResponseStyleEnabled(nextSettings.responseStyleEnabled); setResponseStylePreset(nextSettings.responseStylePreset); setResponseStyleCustomInstructions(nextSettings.responseStyleCustomInstructions); @@ -194,7 +184,7 @@ export const BehaviorPage: React.FC = () => { void load(); return () => abort.abort(); - }, [setSettingsWorktreeFetchSource]); + }, []); React.useEffect(() => { if (isLoading) return; @@ -275,21 +265,6 @@ export const BehaviorPage: React.FC = () => { } }; - const handleWorktreeFetchSourceChange = (enabled: boolean) => { - const previous = worktreeFetchSource; - setWorktreeFetchSource(enabled); - setSettingsWorktreeFetchSource(enabled); - void saveBehaviorSetting( - { worktreeFetchSource: enabled }, - t('settings.behavior.page.toast.saveFailed'), - ).catch((error) => { - setWorktreeFetchSource(previous); - setSettingsWorktreeFetchSource(previous); - const message = error instanceof Error ? error.message : t('settings.behavior.page.toast.saveFailed'); - toast.error(message); - }); - }; - const handleSavePromptOptimization = async () => { setIsApplyingPromptOptimization(true); try { @@ -343,21 +318,6 @@ export const BehaviorPage: React.FC = () => { )} - - - - { const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget); const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory); const deleteSession = sessionActions.deleteSession; - const archiveSession = sessionActions.archiveSession; const deleteSessions = useSessionUIStore((s) => s.deleteSessions); const archiveSessions = useSessionUIStore((s) => s.archiveSessions); const showDeletionDialog = useUIStore((state) => state.showDeletionDialog); @@ -333,7 +332,8 @@ export const SessionDialogs: React.FC = () => { const removeSelectedWorktree = React.useCallback(async ( worktree: WorktreeMetadata, - deleteLocalBranch: boolean + deleteLocalBranch: boolean, + toastId?: string | number, ): Promise => { const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; const projectRef = getProjectRefForWorktree(worktree); @@ -366,7 +366,8 @@ export const SessionDialogs: React.FC = () => { return true; } catch (error) { - toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), { + toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle', { name: getWorktreeDisplayName(worktree) }), { + id: toastId, description: renderToastDescription(error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain')), }); return false; @@ -375,22 +376,45 @@ export const SessionDialogs: React.FC = () => { const removeSelectedWorktreeInBackground = React.useCallback(( worktree: WorktreeMetadata, + sessionIds: string[], deleteLocalBranch: boolean ): void => { const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches; + const toastId = toast.loading(t('sessions.sidebar.sessionDialogs.worktree.removingTitle', { name: getWorktreeDisplayName(worktree) })); void (async () => { - const removed = await removeSelectedWorktree(worktree, deleteLocalBranch); - if (!removed) { - return; + try { + if (sessionIds.length > 0) { + const { failedIds } = await archiveSessions(sessionIds); + if (failedIds.length > 0) { + toast.error(failedIds.length === 1 + ? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length }) + : t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }), { + id: toastId, + description: renderToastDescription(t('sessions.sidebar.dialogs.deleteResult.tryAgain')), + }); + return; + } + } + + const removed = await removeSelectedWorktree(worktree, deleteLocalBranch, toastId); + if (!removed) { + return; + } + const archiveNote = shouldRemoveRemote + ? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote') + : t('sessions.sidebar.sessionDialogs.worktree.removed'); + toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle', { name: getWorktreeDisplayName(worktree) }), { + id: toastId, + description: renderToastDescription(archiveNote), + }); + } catch (error) { + toast.error(t('sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle'), { + id: toastId, + description: renderToastDescription(error instanceof Error ? error.message : t('sessions.sidebar.dialogs.deleteResult.tryAgain')), + }); } - const archiveNote = shouldRemoveRemote - ? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote') - : t('sessions.sidebar.sessionDialogs.worktree.removed'); - toast.success(t('sessions.sidebar.sessionDialogs.worktree.removedTitle'), { - description: renderToastDescription(archiveNote), - }); })(); - }, [canRemoveRemoteBranches, deleteDialogShouldRemoveRemote, removeSelectedWorktree, t]); + }, [archiveSessions, canRemoveRemoteBranches, deleteDialogShouldRemoveRemote, removeSelectedWorktree, t]); const handleConfirmDelete = React.useCallback(async () => { if (!deleteDialog) { @@ -403,21 +427,21 @@ export const SessionDialogs: React.FC = () => { const removeRemoteBranch = shouldArchive && deleteDialogShouldRemoveRemote; const deleteLocalBranch = shouldArchive && deleteDialogShouldDeleteLocalBranch; - if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) { - removeSelectedWorktreeInBackground(deleteDialog.worktree, deleteLocalBranch); + if (isWorktreeDelete && deleteDialog.worktree) { + removeSelectedWorktreeInBackground( + deleteDialog.worktree, + deleteDialog.sessions.map((session) => session.id), + deleteLocalBranch, + ); closeDeleteDialog(); return; } if (deleteDialog.sessions.length === 1) { const target = deleteDialog.sessions[0]; - const success = isWorktreeDelete - ? await archiveSession(target.id) - : await deleteSession(target.id); + const success = await deleteSession(target.id); if (!success) { - toast.error(isWorktreeDelete - ? t('sessions.sidebar.session.archive.error') - : t('sessions.sidebar.session.delete.error')); + toast.error(t('sessions.sidebar.session.delete.error')); setIsProcessingDelete(false); return; } @@ -426,9 +450,7 @@ export const SessionDialogs: React.FC = () => { ? t('sessions.sidebar.sessionDialogs.worktree.removedWithRemote') : t('sessions.sidebar.sessionDialogs.worktree.attachedArchived') : undefined; - toast.success(isWorktreeDelete - ? t('sessions.sidebar.session.archive.success') - : t('sessions.sidebar.session.delete.success'), { + toast.success(t('sessions.sidebar.session.delete.success'), { description: renderToastDescription(archiveNote), action: { label: t('sessions.sidebar.sessionDialogs.ok'), @@ -439,22 +461,9 @@ export const SessionDialogs: React.FC = () => { const ids = deleteDialog.sessions.map((session) => session.id); let deletedIds: string[] = []; let failedIds: string[] = []; - if (isWorktreeDelete) { - const result = await archiveSessions(ids); - deletedIds = result.archivedIds; - failedIds = result.failedIds; - } else { - const result = await deleteSessions(ids); - deletedIds = result.deletedIds; - failedIds = result.failedIds; - } - - if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) { - // Remove selected worktree even if per-session metadata is missing. - // Use same projectRef logic as the no-sessions path. - removeSelectedWorktreeInBackground(deleteDialog.worktree, deleteLocalBranch); - // sync handles session refresh automatically - } + const result = await deleteSessions(ids); + deletedIds = result.deletedIds; + failedIds = result.failedIds; if (deletedIds.length > 0) { const archiveNote = !isWorktreeDelete && shouldArchive @@ -464,24 +473,16 @@ export const SessionDialogs: React.FC = () => { : undefined; const successDescription = failedIds.length > 0 - ? (isWorktreeDelete - ? (failedIds.length === 1 - ? t('sessions.sidebar.dialogs.deleteResult.singleFailedToArchive', { count: failedIds.length }) - : t('sessions.sidebar.dialogs.deleteResult.manyFailedToArchive', { count: failedIds.length })) - : (failedIds.length === 1 + ? (failedIds.length === 1 ? t('sessions.sidebar.dialogs.deleteResult.singleFailedToDelete', { count: failedIds.length }) - : t('sessions.sidebar.dialogs.deleteResult.manyFailedToDelete', { count: failedIds.length }))) + : t('sessions.sidebar.dialogs.deleteResult.manyFailedToDelete', { count: failedIds.length })) : deleteDialog.dateLabel ? t('sessions.sidebar.dialogs.deleteResult.removedFromDate', { dateLabel: deleteDialog.dateLabel }) : undefined; const combinedDescription = [successDescription, archiveNote].filter(Boolean).join(' '); - toast.success(isWorktreeDelete - ? (deletedIds.length === 1 - ? t('sessions.sidebar.bulkActions.archivedSingle', { count: deletedIds.length }) - : t('sessions.sidebar.bulkActions.archivedPlural', { count: deletedIds.length })) - : (deletedIds.length === 1 + toast.success(deletedIds.length === 1 ? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length }) - : t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length })), { + : t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }), { description: renderToastDescription(combinedDescription || undefined), action: { label: t('sessions.sidebar.sessionDialogs.ok'), @@ -491,13 +492,9 @@ export const SessionDialogs: React.FC = () => { } if (failedIds.length > 0) { - toast.error(isWorktreeDelete - ? (failedIds.length === 1 - ? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length }) - : t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length })) - : (failedIds.length === 1 + toast.error(failedIds.length === 1 ? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length }) - : t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length })), { + : t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }), { description: renderToastDescription(t('sessions.sidebar.dialogs.deleteResult.tryAgain')), }); if (deletedIds.length === 0) { @@ -507,11 +504,6 @@ export const SessionDialogs: React.FC = () => { } } - if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) { - removeSelectedWorktreeInBackground(deleteDialog.worktree, deleteLocalBranch); - // sync bootstrap refreshes sessions automatically - } - closeDeleteDialog(); } finally { setIsProcessingDelete(false); @@ -522,8 +514,6 @@ export const SessionDialogs: React.FC = () => { deleteDialogShouldDeleteLocalBranch, deleteSession, deleteSessions, - archiveSession, - archiveSessions, closeDeleteDialog, shouldArchiveWorktree, isWorktreeDelete, diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 910896cb..4cceb29f 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -438,6 +438,7 @@ export interface GitWorktreeCreateResult { path: string; directoryCreated?: true; bootstrapStatus?: GitWorktreeBootstrapStatus; + sourceFetchFailed?: true; } export interface RemoveGitWorktreePayload { diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 573c0bf5..3da4af7f 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -146,7 +146,6 @@ export type DesktopSettings = { defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id openInAppId?: string; autoCreateWorktree?: boolean; - worktreeFetchSource?: boolean; followUpBehavior?: 'steer' | 'queue'; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 29d5db88..8e7ad3c5 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -2188,10 +2188,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': 'Größe des System-Prompts optimieren', 'settings.behavior.page.systemPromptOptimization.enableAria': 'Größe des OpenCode-System-Prompts optimieren', 'settings.behavior.page.systemPromptOptimization.info': 'Reduziert den System-Prompt für die Build- und Plan-Agenten schätzungsweise um 40 %. Andere Agenten bleiben unverändert. Dadurch können benutzerdefinierte Definitionen entfernt werden, die Build oder Plan überschreiben, daher nicht für Workflows aktivieren, die diese Agenten anpassen. Ein Neustart von OpenCode wendet die Änderung an.', - 'settings.behavior.page.section.worktrees': 'Worktrees', - 'settings.behavior.page.worktreeFetchSource.enable': 'Remote vor dem Erstellen von Worktrees abrufen', - 'settings.behavior.page.worktreeFetchSource.enableAria': 'Änderungen vom Remote abrufen, bevor Worktrees erstellt werden', - 'settings.behavior.page.worktreeFetchSource.info': 'Neue Worktrees verzweigen vom abgerufenen Remote-Stand des aktuellen Branches statt vom lokalen Checkout und sind dadurch nicht veraltet. Beim Erstellen wird der lokale Stand verwendet, wenn das Remote nicht erreichbar ist.', 'settings.behavior.page.systemPromptOptimization.restarting': 'OpenCode wird neu gestartet, um die System-Prompt-Optimierung anzuwenden…', 'settings.openchamber.desktopNetwork.field.windowControls': 'Fenstersteuerungen', 'settings.openchamber.desktopNetwork.field.windowControlsStyle': 'Stil', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index dfd97107..3036942f 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -600,8 +600,9 @@ export const dict = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': 'Desktop konnte keinen Dateizugriff gewähren.', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': 'Fehler beim Hinzufügen des Projekts', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': 'Bitte wählen Sie einen gültigen Verzeichnispfad aus.', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Fehler beim Entfernen des Worktrees', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree entfernt', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Fehler beim Entfernen des Worktrees „{name}“', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': 'Worktree „{name}“ wird entfernt…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree „{name}“ entfernt', 'sessions.sidebar.sessionDialogs.worktree.removed': 'Worktree entfernt.', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'Worktree und Remote-Branch entfernt.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Angefügter Worktree archiviert.', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 34a44530..4b44ad31 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -607,10 +607,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': 'Optimize system prompt size', 'settings.behavior.page.systemPromptOptimization.enableAria': 'Optimize OpenCode system prompt size', 'settings.behavior.page.systemPromptOptimization.info': 'Reduces the system prompt by an estimated 40% for the build and plan agents. Other agents are not changed. This can remove custom definitions that override build or plan, so do not enable it for workflows that customize those agents. Restarting OpenCode applies the change.', - 'settings.behavior.page.section.worktrees': 'Worktrees', - 'settings.behavior.page.worktreeFetchSource.enable': 'Fetch remote before creating worktrees', - 'settings.behavior.page.worktreeFetchSource.enableAria': 'Fetch changes from remote before creating worktrees', - 'settings.behavior.page.worktreeFetchSource.info': 'New worktrees branch from the fetched remote state of the current branch instead of the local checkout, so they are not stale. Creation falls back to the local state when the remote is unreachable.', 'settings.behavior.page.systemPromptOptimization.restarting': 'Restarting OpenCode to apply system prompt optimization…', 'settings.behavior.page.field.systemPromptPlaceholder': 'You are a helpful AI assistant...\n\nUse this space to define absolute rules for how the AI should behave across all sessions and providers.', 'settings.behavior.page.section.responseStyle': 'Response style', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index e9c8d045..af1d6167 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -696,8 +696,9 @@ export const dict = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': 'Desktop could not grant file access.', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': 'Failed to add project', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': 'Please select a valid directory path.', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Failed to remove worktree', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree removed', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Failed to remove worktree “{name}”', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': 'Removing worktree “{name}”…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree “{name}” removed', 'sessions.sidebar.sessionDialogs.worktree.removed': 'Worktree removed.', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'Worktree and remote branch removed.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Attached worktree archived.', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 54011980..2b656990 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -575,10 +575,6 @@ export const settingsDict = { "settings.behavior.page.systemPromptOptimization.enable": "Optimizar el tamaño del prompt del sistema", "settings.behavior.page.systemPromptOptimization.enableAria": "Optimizar el tamaño del prompt del sistema de OpenCode", "settings.behavior.page.systemPromptOptimization.info": "Reduce el prompt del sistema aproximadamente un 40 % para los agentes build y plan. Los demás agentes no cambian. Puede eliminar definiciones personalizadas que sobrescriban build o plan, así que no lo actives en esos flujos de trabajo. El cambio se aplica al reiniciar OpenCode.", - "settings.behavior.page.section.worktrees": "Worktrees", - "settings.behavior.page.worktreeFetchSource.enable": "Obtener cambios del remoto antes de crear worktrees", - "settings.behavior.page.worktreeFetchSource.enableAria": "Obtener cambios del repositorio remoto antes de crear worktrees", - "settings.behavior.page.worktreeFetchSource.info": "Los nuevos worktrees se crean a partir del estado remoto obtenido de la rama actual en lugar del checkout local, por lo que no quedan obsoletos. La creación usa el estado local si el remoto no está accesible.", "settings.behavior.page.systemPromptOptimization.restarting": "Reiniciando OpenCode para aplicar la optimización del prompt del sistema…", "settings.behavior.page.field.systemPromptPlaceholder": "Eres un asistente de IA útil...\n\nUsa este espacio para definir reglas absolutas sobre cómo la IA debe comportarse en todas las sesiones y proveedores.", "settings.behavior.page.section.responseStyle": "Estilo de respuesta", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 672ad084..c1ca296f 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -697,8 +697,9 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.directory.errorOpenDescription": "Desktop no pudo otorgar acceso a archivos.", "sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle": "No se pudo añadir proyecto", "sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription": "Selecciona una ruta de directorio válida.", - "sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle": "No se pudo eliminar worktree", - "sessions.sidebar.sessionDialogs.worktree.removedTitle": "Worktree eliminado", + "sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle": "No se pudo eliminar el worktree «{name}»", + "sessions.sidebar.sessionDialogs.worktree.removingTitle": "Eliminando el worktree «{name}»…", + "sessions.sidebar.sessionDialogs.worktree.removedTitle": "Worktree «{name}» eliminado", "sessions.sidebar.sessionDialogs.worktree.removed": "Worktree eliminado.", "sessions.sidebar.sessionDialogs.worktree.removedWithRemote": "Worktree y rama remota eliminados.", "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Worktree adjunto archivado.", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 8b0acc56..0fe60bea 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -493,10 +493,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': 'Optimiser la taille du prompt système', 'settings.behavior.page.systemPromptOptimization.enableAria': 'Optimiser la taille du prompt système d’OpenCode', 'settings.behavior.page.systemPromptOptimization.info': 'Réduit le prompt système d’environ 40 % pour les agents build et plan. Les autres agents ne sont pas modifiés. Cela peut supprimer les définitions personnalisées qui remplacent build ou plan ; ne l’activez donc pas pour ces workflows. Le redémarrage d’OpenCode applique la modification.', - 'settings.behavior.page.section.worktrees': 'Worktrees', - 'settings.behavior.page.worktreeFetchSource.enable': 'Récupérer le dépôt distant avant de créer des worktrees', - 'settings.behavior.page.worktreeFetchSource.enableAria': 'Récupérer les changements du dépôt distant avant de créer des worktrees', - 'settings.behavior.page.worktreeFetchSource.info': 'Les nouveaux worktrees sont créés depuis l’état distant récupéré de la branche courante plutôt que depuis le checkout local, afin d’éviter qu’ils ne soient obsolètes. La création utilise l’état local si le dépôt distant est inaccessible.', 'settings.behavior.page.systemPromptOptimization.restarting': 'Redémarrage d’OpenCode pour appliquer l’optimisation du prompt système…', 'settings.behavior.page.field.systemPromptPlaceholder': 'Vous êtes un assistant IA utile...\n\nUtilisez cet espace pour définir des règles absolues sur la façon dont l\'IA doit se comporter dans toutes les sessions et tous les fournisseurs.', 'settings.behavior.page.section.responseStyle': 'Style de réponse', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 70eab598..2804fdeb 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -525,8 +525,9 @@ export const dict = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': 'Desktop n\'a pas pu accorder l\'accès aux fichiers.', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': 'Échec de l\'ajout du projet', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': 'Veuillez sélectionner un chemin de répertoire valide.', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Échec de la suppression du worktree', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree supprimé', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Échec de la suppression du worktree « {name} »', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': 'Suppression du worktree « {name} »…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree « {name} » supprimé', 'sessions.sidebar.sessionDialogs.worktree.removed': 'Worktree supprimé.', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'Worktree et branche du dépôt distant supprimés.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Worktree ci-joint archivé.', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 25468113..729551a6 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -608,10 +608,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': 'システムプロンプトのサイズを最適化', 'settings.behavior.page.systemPromptOptimization.enableAria': 'OpenCode のシステムプロンプトサイズを最適化', 'settings.behavior.page.systemPromptOptimization.info': 'build と plan エージェントのシステムプロンプトを約 40% 削減します。他のエージェントは変更されません。build または plan を上書きするカスタム定義が削除される可能性があるため、そのようなワークフローでは有効にしないでください。OpenCode の再起動後に変更が適用されます。', - 'settings.behavior.page.section.worktrees': 'ワークツリー', - 'settings.behavior.page.worktreeFetchSource.enable': 'ワークツリー作成前にリモートから取得', - 'settings.behavior.page.worktreeFetchSource.enableAria': 'ワークツリー作成前にリモートから変更を取得する', - 'settings.behavior.page.worktreeFetchSource.info': '新しいワークツリーは、ローカルのチェックアウトではなく現在のブランチのリモート状態を取得して作成されるため、最新の状態になります。リモートにアクセスできない場合はローカルの状態で作成されます。', 'settings.behavior.page.systemPromptOptimization.restarting': 'システムプロンプトの最適化を適用するため OpenCode を再起動しています…', 'settings.behavior.page.field.systemPromptPlaceholder': 'あなたは役立つ AI アシスタントです...\n\nこのスペースを使用して、すべての Session と Provider にわたって AI の動作方法に関する絶対的なルールを定義してください。', 'settings.behavior.page.section.responseStyle': '応答スタイル', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 8b6d7bc8..bbf1c0a9 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -697,8 +697,9 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': 'デスクトップがファイルアクセスを許可しませんでした。', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': 'プロジェクトの追加に失敗しました', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': '有効なディレクトリパスを選択してください。', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'ワークツリーの削除に失敗しました', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'ワークツリーを削除しました', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'ワークツリー「{name}」の削除に失敗しました', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': 'ワークツリー「{name}」を削除しています…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'ワークツリー「{name}」を削除しました', 'sessions.sidebar.sessionDialogs.worktree.removed': 'ワークツリーを削除しました。', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'ワークツリーとリモートブランチを削除しました。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '添付のワークツリーをアーカイブしました。', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 27de0706..48d6aa8b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -575,10 +575,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': '시스템 프롬프트 크기 최적화', 'settings.behavior.page.systemPromptOptimization.enableAria': 'OpenCode 시스템 프롬프트 크기 최적화', 'settings.behavior.page.systemPromptOptimization.info': 'build 및 plan 에이전트의 시스템 프롬프트를 약 40% 줄입니다. 다른 에이전트는 변경되지 않습니다. build 또는 plan을 재정의하는 사용자 지정 정의가 제거될 수 있으므로 이러한 워크플로에서는 활성화하지 마세요. OpenCode를 다시 시작하면 변경 사항이 적용됩니다.', - 'settings.behavior.page.section.worktrees': '워크트리', - 'settings.behavior.page.worktreeFetchSource.enable': '워크트리를 만들기 전에 원격에서 가져오기', - 'settings.behavior.page.worktreeFetchSource.enableAria': '워크트리를 만들기 전에 원격에서 변경 사항 가져오기', - 'settings.behavior.page.worktreeFetchSource.info': '새 워크트리는 로컬 체크아웃 대신 현재 브랜치의 원격 상태를 가져와서 만들어지므로 최신 상태를 유지합니다. 원격에 접근할 수 없으면 로컬 상태로 만듭니다.', 'settings.behavior.page.systemPromptOptimization.restarting': '시스템 프롬프트 최적화를 적용하기 위해 OpenCode를 다시 시작하는 중…', 'settings.behavior.page.field.systemPromptPlaceholder': '당신은 유용한 AI 어시스턴트입니다...\n\n모든 세션과 공급자에서 AI가 어떻게 행동해야 하는지에 대한 절대적인 규칙을 정의할 수 있습니다.', 'settings.behavior.page.section.responseStyle': '응답 스타일', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index c4b55cad..64d3ddad 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -697,8 +697,9 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': '데스크톱에서 파일 접근 권한을 부여하지 못했습니다.', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': '프로젝트 추가 실패', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': '유효한 디렉터리 경로를 선택하세요.', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': '워크트리 제거 실패', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': '워크트리 제거됨', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': '워크트리 “{name}” 제거 실패', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': '워크트리 “{name}” 제거 중…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': '워크트리 “{name}” 제거됨', 'sessions.sidebar.sessionDialogs.worktree.removed': '워크트리가 제거되었습니다.', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': '워크트리와 리모트 브랜치가 제거되었습니다.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '첨부됨 워크트리 보관됨.', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index dc20fd6a..9d89d843 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -160,10 +160,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': 'Optymalizuj rozmiar promptu systemowego', 'settings.behavior.page.systemPromptOptimization.enableAria': 'Optymalizuj rozmiar promptu systemowego OpenCode', 'settings.behavior.page.systemPromptOptimization.info': 'Zmniejsza prompt systemowy o około 40% dla agentów build i plan. Inni agenci pozostają bez zmian. Może to usunąć niestandardowe definicje zastępujące build lub plan, dlatego nie włączaj tej opcji w takich przepływach pracy. Zmiana zostanie zastosowana po ponownym uruchomieniu OpenCode.', - 'settings.behavior.page.section.worktrees': 'Drzewa pracy', - 'settings.behavior.page.worktreeFetchSource.enable': 'Pobierz zmiany ze zdalnego przed utworzeniem drzew pracy', - 'settings.behavior.page.worktreeFetchSource.enableAria': 'Pobierz zmiany ze zdalnego repozytorium przed utworzeniem drzew pracy', - 'settings.behavior.page.worktreeFetchSource.info': 'Nowe drzewa pracy powstają z pobranego zdalnego stanu bieżącej gałęzi, a nie z lokalnej kopii roboczej, dzięki czemu nie są nieaktualne. Jeśli zdalne repozytorium jest niedostępne, używany jest stan lokalny.', 'settings.behavior.page.systemPromptOptimization.restarting': 'Ponowne uruchamianie OpenCode w celu zastosowania optymalizacji promptu systemowego…', 'settings.behavior.page.title': 'Zachowanie', 'settings.behavior.page.toast.saveFailed': 'Nie udało się zapisać zachowania', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index e48c6616..2b030572 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -697,8 +697,9 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': 'Aplikacja desktopowa nie mogła nadać dostępu do plików.', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': 'Nie udało się dodać projektu', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': 'Wybierz prawidłową ścieżkę katalogu.', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Nie udało się usunąć drzewa pracy', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Drzewo pracy usunięte', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Nie udało się usunąć drzewa pracy „{name}”', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': 'Usuwanie drzewa pracy „{name}”…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Drzewo pracy „{name}” usunięte', 'sessions.sidebar.sessionDialogs.worktree.removed': 'Drzewo pracy usunięte.', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'Drzewo pracy i zdalna gałąź usunięte.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Dołączone drzewo pracy zarchiwizowane.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 9d878cce..9261f8ca 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -575,10 +575,6 @@ export const settingsDict = { "settings.behavior.page.systemPromptOptimization.enable": "Otimizar o tamanho do prompt do sistema", "settings.behavior.page.systemPromptOptimization.enableAria": "Otimizar o tamanho do prompt do sistema do OpenCode", "settings.behavior.page.systemPromptOptimization.info": "Reduz o prompt do sistema em cerca de 40% para os agentes build e plan. Os outros agentes não são alterados. Isso pode remover definições personalizadas que substituem build ou plan, portanto não ative nesses fluxos de trabalho. A alteração é aplicada após reiniciar o OpenCode.", - "settings.behavior.page.section.worktrees": "Worktrees", - "settings.behavior.page.worktreeFetchSource.enable": "Buscar do remoto antes de criar worktrees", - "settings.behavior.page.worktreeFetchSource.enableAria": "Buscar alterações do remoto antes de criar worktrees", - "settings.behavior.page.worktreeFetchSource.info": "Novos worktrees são criados a partir do estado remoto buscado do branch atual, e não do checkout local, para que não fiquem desatualizados. A criação usa o estado local quando o remoto está inacessível.", "settings.behavior.page.systemPromptOptimization.restarting": "Reiniciando o OpenCode para aplicar a otimização do prompt do sistema…", "settings.behavior.page.field.systemPromptPlaceholder": "Você é um assistente de IA útil...\n\nUse este espaço para definir regras absolutas sobre como a IA deve se comportar em todas as sessões e provedores.", "settings.behavior.page.section.responseStyle": "Estilo de resposta", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 3c34a646..0636e87e 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -697,8 +697,9 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.directory.errorOpenDescription": "O desktop não pôde conceder acesso aos arquivos.", "sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle": "Não foi possível adicionar projeto", "sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription": "Selecione uma caminho de diretório válida.", - "sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle": "Não foi possível excluir worktree", - "sessions.sidebar.sessionDialogs.worktree.removedTitle": "Worktree excluído", + "sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle": "Não foi possível excluir o worktree “{name}”", + "sessions.sidebar.sessionDialogs.worktree.removingTitle": "Excluindo o worktree “{name}”…", + "sessions.sidebar.sessionDialogs.worktree.removedTitle": "Worktree “{name}” excluído", "sessions.sidebar.sessionDialogs.worktree.removed": "Worktree excluído.", "sessions.sidebar.sessionDialogs.worktree.removedWithRemote": "Worktree e branch remota excluídos.", "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Worktree adjunto archivado.", diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index 02026c04..02651073 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -603,10 +603,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': 'System prompt boyutunu optimize et', 'settings.behavior.page.systemPromptOptimization.enableAria': 'OpenCode system prompt boyutunu optimize et', 'settings.behavior.page.systemPromptOptimization.info': 'System prompt\'u, build ve plan agent\'ları için tahmini %40 küçültür. Diğer agent\'lar değişmez. Bu, build veya plan\'ı geçersiz kılan özel tanımları kaldırabileceği için bu agent\'ları özelleştiren iş akışlarında etkinleştirmeyin. Değişiklik, OpenCode yeniden başlatıldığında uygulanır.', - 'settings.behavior.page.section.worktrees': 'Worktree\'ler', - 'settings.behavior.page.worktreeFetchSource.enable': 'Worktree oluşturmadan önce uzak depodan fetch et', - 'settings.behavior.page.worktreeFetchSource.enableAria': 'Worktree oluşturmadan önce uzak depodan değişiklikleri fetch et', - 'settings.behavior.page.worktreeFetchSource.info': 'Yeni worktree\'ler yerel checkout yerine geçerli dalın uzak depodan fetch edilen durumuna dayanır, böylece güncel kalır. Uzak depoya erişilemezse yerel durum kullanılır.', 'settings.behavior.page.systemPromptOptimization.restarting': 'System prompt optimizasyonunu uygulamak için OpenCode yeniden başlatılıyor…', 'settings.behavior.page.field.systemPromptPlaceholder': 'Sen yardımcı bir AI asistanısın...\\n\\nBu alanı, AI\'ın tüm session\'lar ve provider\'lar genelinde nasıl davranacağına dair mutlak kuralları tanımlamak için kullanın.', 'settings.behavior.page.section.responseStyle': 'Yanıt stili', diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index ee421fe1..775fe6b6 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -678,8 +678,9 @@ export const dict = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': 'Masaüstü uygulaması dosya erişimi izni veremedi.', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': 'Proje eklenemedi', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': 'Geçerli bir dizin yolu seçin.', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Worktree kaldırılamadı', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree kaldırıldı', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': '“{name}” worktree kaldırılamadı', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': '“{name}” worktree kaldırılıyor…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': '“{name}” worktree kaldırıldı', 'sessions.sidebar.sessionDialogs.worktree.removed': 'Worktree kaldırıldı.', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'Worktree ve uzak branch kaldırıldı.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Bağlı worktree arşivlendi.', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 6acf1ade..4116ef42 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -575,10 +575,6 @@ export const settingsDict = { "settings.behavior.page.systemPromptOptimization.enable": "Оптимізувати розмір системного промпту", "settings.behavior.page.systemPromptOptimization.enableAria": "Оптимізувати розмір системного промпту OpenCode", "settings.behavior.page.systemPromptOptimization.info": "Зменшує системний промпт орієнтовно на 40% для агентів build і plan. Інші агенти не змінюються. Це може видалити власні визначення, які перевизначають build або plan, тому не вмикайте цю функцію для таких робочих процесів. Зміна застосовується після перезапуску OpenCode.", - "settings.behavior.page.section.worktrees": "Worktree", - "settings.behavior.page.worktreeFetchSource.enable": "Отримувати зміни з віддаленого перед створенням worktree", - "settings.behavior.page.worktreeFetchSource.enableAria": "Отримати зміни з віддаленого репозиторію перед створенням worktree", - "settings.behavior.page.worktreeFetchSource.info": "Нові worktree створюються з отриманого віддаленого стану поточної гілки, а не з локального checkout, тому не є застарілими. Якщо віддалений репозиторій недоступний, використовується локальний стан.", "settings.behavior.page.systemPromptOptimization.restarting": "Перезапуск OpenCode для застосування оптимізації системного промпту…", "settings.behavior.page.field.systemPromptPlaceholder": "Ви — корисний AI-асистент...\n\nВикористовуйте цей простір для визначення абсолютних правил поведінки AI для всіх сесій та провайдерів.", "settings.behavior.page.section.responseStyle": "Стиль відповіді", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 3434a635..276dc0c2 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -697,8 +697,9 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.directory.errorOpenDescription": "Десктопний застосунок не зміг надати доступ до файлу.", "sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle": "Не вдалося додати проєкт", "sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription": "Виберіть правильний шлях до каталогу.", - "sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle": "Не вдалося видалити worktree", - "sessions.sidebar.sessionDialogs.worktree.removedTitle": "Worktree видалено", + "sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle": "Не вдалося видалити worktree «{name}»", + "sessions.sidebar.sessionDialogs.worktree.removingTitle": "Видаляємо worktree «{name}»…", + "sessions.sidebar.sessionDialogs.worktree.removedTitle": "Worktree «{name}» видалено", "sessions.sidebar.sessionDialogs.worktree.removed": "Worktree видалено.", "sessions.sidebar.sessionDialogs.worktree.removedWithRemote": "Worktree та віддалену гілку видалено.", "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Прикріплене worktree заархівовано.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 257f57c8..01024ad1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -575,10 +575,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': '优化系统提示词大小', 'settings.behavior.page.systemPromptOptimization.enableAria': '优化 OpenCode 系统提示词大小', 'settings.behavior.page.systemPromptOptimization.info': '预计可将 build 和 plan 代理的系统提示词缩减约 40%,其他代理不会改变。这可能会移除覆盖 build 或 plan 的自定义定义,因此此类工作流请勿启用。重启 OpenCode 后应用更改。', - 'settings.behavior.page.section.worktrees': '工作树', - 'settings.behavior.page.worktreeFetchSource.enable': '创建工作树前先从远程获取', - 'settings.behavior.page.worktreeFetchSource.enableAria': '创建工作树前先从远程仓库获取变更', - 'settings.behavior.page.worktreeFetchSource.info': '新工作树基于当前分支从远程获取的状态创建,而不是本地检出,因此不会滞后。远程不可用时将回退为本地状态。', 'settings.behavior.page.systemPromptOptimization.restarting': '正在重启 OpenCode 以应用系统提示词优化…', 'settings.behavior.page.field.systemPromptPlaceholder': '你是一个有帮助的 AI 助手...\n\n使用此空间定义 AI 在所有会话和提供商中的绝对行为规则。', 'settings.behavior.page.section.responseStyle': '回复风格', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 5de1f7fa..3fcb1c2a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -697,8 +697,9 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': '桌面端无法授予文件访问权限。', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': '添加项目失败', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': '请选择有效的目录路径。', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': '移除工作树失败', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': '工作树已移除', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': '移除工作树“{name}”失败', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': '正在移除工作树“{name}”…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': '工作树“{name}”已移除', 'sessions.sidebar.sessionDialogs.worktree.removed': '工作树已移除。', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': '工作树和远程分支已移除。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '关联工作树已归档。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 341e50f0..7e5d3f25 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -572,10 +572,6 @@ export const settingsDict = { 'settings.behavior.page.systemPromptOptimization.enable': '最佳化系統提示詞大小', 'settings.behavior.page.systemPromptOptimization.enableAria': '最佳化 OpenCode 系統提示詞大小', 'settings.behavior.page.systemPromptOptimization.info': '預計可將 build 和 plan 代理程式的系統提示詞縮減約 40%,其他代理程式不會變更。這可能會移除覆寫 build 或 plan 的自訂定義,因此此類工作流程請勿啟用。重新啟動 OpenCode 後套用變更。', - 'settings.behavior.page.section.worktrees': '工作樹', - 'settings.behavior.page.worktreeFetchSource.enable': '建立工作樹前先從遠端擷取', - 'settings.behavior.page.worktreeFetchSource.enableAria': '建立工作樹前先從遠端儲存庫擷取變更', - 'settings.behavior.page.worktreeFetchSource.info': '新工作樹基於目前分支從遠端擷取的狀態建立,而非本機檢出,因此不會落後。遠端無法使用時將改用本機狀態。', 'settings.behavior.page.systemPromptOptimization.restarting': '正在重新啟動 OpenCode 以套用系統提示詞最佳化…', 'settings.behavior.page.field.systemPromptPlaceholder': '你是一個有幫助的 AI 助助理...\n\n在此設定 AI 在所有工作階段與服務提供者中都必須遵守的絕對行為規則。', 'settings.behavior.page.section.responseStyle': '回覆風格', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 221fc24f..5f4263b7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -710,8 +710,9 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': '桌面端無法授予檔案存取權限。', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': '新增專案失敗', 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': '請選擇有效的目錄路徑。', - 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': '移除 worktree 失敗', - 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'worktree 已移除', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': '移除 worktree「{name}」失敗', + 'sessions.sidebar.sessionDialogs.worktree.removingTitle': '正在移除 worktree「{name}」…', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'worktree「{name}」已移除', 'sessions.sidebar.sessionDialogs.worktree.removed': 'worktree 已移除。', 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'worktree 和遠端分支已移除。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '關聯 worktree 已封存。', diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 97c37ab6..c5750464 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1278,9 +1278,6 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.autoCreateWorktree === 'boolean') { result.autoCreateWorktree = candidate.autoCreateWorktree; } - if (typeof candidate.worktreeFetchSource === 'boolean') { - result.worktreeFetchSource = candidate.worktreeFetchSource; - } if (typeof candidate.gitmojiEnabled === 'boolean') { result.gitmojiEnabled = candidate.gitmojiEnabled; } diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 43127f72..14d0caf2 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -607,12 +607,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.openchamber.worktrees.setup.waitForCommands', keywords: ['worktree', 'setup commands', 'bootstrap', 'wait'], }, - { - id: 'behavior.worktree-fetch-source', - page: 'behavior', - titleKey: 'settings.behavior.page.section.worktrees', - keywords: ['worktree', 'fetch', 'remote', 'source branch', 'stale'], - }, { id: 'remote-instances.client-auth', page: 'remote-instances', diff --git a/packages/ui/src/lib/worktrees/worktreeCreate.test.ts b/packages/ui/src/lib/worktrees/worktreeCreate.test.ts index 20e0a51b..642e10db 100644 --- a/packages/ui/src/lib/worktrees/worktreeCreate.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeCreate.test.ts @@ -7,7 +7,6 @@ interface MockBranchTracking { const project: ProjectRef = { id: 'project-1', path: '/repo' }; -let fetchSourceEnabled = true; let projectRoot = '/repo'; let gitStatus: { current: string; @@ -16,25 +15,7 @@ let gitStatus: { behind: number; } | null = null; let branchTracking: MockBranchTracking = {}; -let gitFetchError: Error | null = null; -let gitFetchSuccess = true; -const gitFetchCalls: Array<{ directory: string; remote?: string; branch?: string }> = []; const createdPayloads: CreateWorktreeArgs[] = []; -const toastWarnings: string[] = []; - -mock.module('@/components/ui', () => ({ - toast: { - warning: (message: string) => { - toastWarnings.push(message); - }, - }, -})); - -mock.module('@/stores/useConfigStore', () => ({ - useConfigStore: { - getState: () => ({ settingsWorktreeFetchSource: fetchSourceEnabled }), - }, -})); mock.module('@/lib/gitApi', () => ({ getGitStatus: () => (gitStatus ? Promise.resolve(gitStatus) : Promise.reject(new Error('no status'))), @@ -45,13 +26,6 @@ mock.module('@/lib/gitApi', () => ({ ? { main: { current: false, name: 'main', commit: 'abc1234', label: '', tracking: branchTracking.main } } : {}, }), - gitFetch: (directory: string, options: { remote?: string; branch?: string } = {}) => { - gitFetchCalls.push({ directory, ...options }); - if (gitFetchError) { - return Promise.reject(gitFetchError); - } - return Promise.resolve({ success: gitFetchSuccess }); - }, })); mock.module('@/lib/worktrees/worktreeStatus', () => ({ @@ -78,7 +52,7 @@ mock.module('@/lib/worktrees/worktreeManager', () => ({ }, })); -const { createWorktreeWithDefaults, withWorktreeFetchedStartRef } = await import('./worktreeCreate'); +const { createWorktreeWithDefaults, withWorktreeRemoteStartRef } = await import('./worktreeCreate'); const baseArgs = (overrides: CreateWorktreeArgs = {}): CreateWorktreeArgs => ({ preferredName: 'openchamber/feature', @@ -88,174 +62,116 @@ const baseArgs = (overrides: CreateWorktreeArgs = {}): CreateWorktreeArgs => ({ ...overrides, }); -describe('withWorktreeFetchedStartRef', () => { +describe('withWorktreeRemoteStartRef', () => { beforeEach(() => { - fetchSourceEnabled = true; projectRoot = '/repo'; gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 0 }; branchTracking = {}; - gitFetchError = null; - gitFetchSuccess = true; - gitFetchCalls.length = 0; createdPayloads.length = 0; - toastWarnings.length = 0; }); - test('bases the new worktree on the fetched remote-tracking ref', async () => { + test('selects the tracked remote ref for the runtime to fetch', async () => { gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; - const args = await withWorktreeFetchedStartRef(project, baseArgs()); + const args = await withWorktreeRemoteStartRef(project, baseArgs()); expect(args.startRef).toBe('remotes/origin/main'); - expect(gitFetchCalls).toEqual([{ directory: '/repo', remote: 'origin', branch: 'main' }]); }); - test('fetches the branch the explicit start ref names when it is the current branch', async () => { + test('selects the tracked remote ref when the explicit start ref names the current branch', async () => { gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 3 }; - const args = await withWorktreeFetchedStartRef(project, baseArgs({ startRef: 'main' })); + const args = await withWorktreeRemoteStartRef(project, baseArgs({ startRef: 'main' })); expect(args.startRef).toBe('remotes/origin/main'); - expect(gitFetchCalls).toHaveLength(1); }); - test('falls back to the original args when the fetch fails', async () => { - gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; - gitFetchError = new Error('network down'); - - const args = baseArgs(); - const resolved = await withWorktreeFetchedStartRef(project, args); - - expect(resolved).toBe(args); - expect(resolved.startRef).toBe(undefined); - expect(gitFetchCalls).toHaveLength(1); - expect(toastWarnings).toHaveLength(1); - }); - - test('falls back to the original args when the fetch resolves unsuccessful', async () => { - gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; - gitFetchSuccess = false; - - const args = baseArgs(); - const resolved = await withWorktreeFetchedStartRef(project, args); - - expect(resolved).toBe(args); - expect(resolved.startRef).toBe(undefined); - expect(gitFetchCalls).toHaveLength(1); - expect(toastWarnings).toHaveLength(1); - }); - - test('does not fetch when disabled in config', async () => { - fetchSourceEnabled = false; - - const args = baseArgs(); - const resolved = await withWorktreeFetchedStartRef(project, args); - - expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); - expect(toastWarnings).toHaveLength(0); - }); - - test('does not fetch when the base branch has local-only commits', async () => { + test('keeps the local base when it has unpublished commits', async () => { gitStatus = { current: 'main', tracking: 'origin/main', ahead: 2, behind: 15 }; const args = baseArgs(); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); - test('does not fetch without upstream tracking', async () => { + test('keeps the local base without upstream tracking', async () => { gitStatus = { current: 'main', tracking: null, ahead: 0, behind: 0 }; const args = baseArgs(); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); - test('does not fetch when git status is unavailable', async () => { + test('keeps the requested base when git status is unavailable', async () => { gitStatus = null; const args = baseArgs(); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); test('keeps an explicit non-root local start ref untouched', async () => { gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; const args = baseArgs({ startRef: 'release/1.2' }); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); test('keeps an explicit remote start ref untouched', async () => { const args = baseArgs({ startRef: 'remotes/origin/main' }); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); test('keeps a commit SHA start ref untouched', async () => { const sha = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0'; const args = baseArgs({ startRef: sha }); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); - test('does not fetch in existing mode', async () => { + test('keeps the requested base in existing mode', async () => { const args = baseArgs({ mode: 'existing', existingBranch: 'origin/feature' }); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); - test('does not fetch on a detached root checkout', async () => { + test('keeps the requested base on a detached root checkout', async () => { gitStatus = { current: '', tracking: null, ahead: 0, behind: 0 }; const args = baseArgs(); - const resolved = await withWorktreeFetchedStartRef(project, args); + const resolved = await withWorktreeRemoteStartRef(project, args); expect(resolved).toBe(args); - expect(gitFetchCalls).toHaveLength(0); }); test('reads the root checkout state, not the project directory state', async () => { projectRoot = '/primary'; gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; - const args = await withWorktreeFetchedStartRef(project, baseArgs()); + const args = await withWorktreeRemoteStartRef(project, baseArgs()); expect(args.startRef).toBe('remotes/origin/main'); - expect(gitFetchCalls).toEqual([{ directory: '/repo', remote: 'origin', branch: 'main' }]); }); }); -describe('createWorktreeWithDefaults fetch integration', () => { +describe('createWorktreeWithDefaults remote source integration', () => { beforeEach(() => { - fetchSourceEnabled = true; projectRoot = '/repo'; gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 0 }; branchTracking = { main: 'origin/main' }; - gitFetchError = null; - gitFetchSuccess = true; - gitFetchCalls.length = 0; createdPayloads.length = 0; - toastWarnings.length = 0; }); - test('sets the new branch\'s own upstream when refreshed from remote', async () => { + test('sets the new branch\'s own upstream when using the tracked remote source', async () => { gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; await createWorktreeWithDefaults(project, baseArgs()); @@ -279,19 +195,6 @@ describe('createWorktreeWithDefaults fetch integration', () => { expect(createdPayloads[0].upstreamBranch).toBe('openchamber/feature'); }); - test('keeps upstream defaults when the fetch falls back', async () => { - gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; - gitFetchError = new Error('network down'); - - await createWorktreeWithDefaults(project, baseArgs()); - - expect(createdPayloads).toHaveLength(1); - expect(createdPayloads[0].startRef).toBe(undefined); - expect(createdPayloads[0].setUpstream).toBe(true); - expect(createdPayloads[0].upstreamRemote).toBe('origin'); - expect(createdPayloads[0].upstreamBranch).toBe('openchamber/feature'); - }); - test('passes an explicit remote start ref through with upstream defaults as before', async () => { await createWorktreeWithDefaults(project, baseArgs({ startRef: 'remotes/origin/main' })); @@ -300,6 +203,5 @@ describe('createWorktreeWithDefaults fetch integration', () => { expect(createdPayloads[0].setUpstream).toBe(true); expect(createdPayloads[0].upstreamRemote).toBe('origin'); expect(createdPayloads[0].upstreamBranch).toBe('openchamber/feature'); - expect(gitFetchCalls).toHaveLength(0); }); }); diff --git a/packages/ui/src/lib/worktrees/worktreeCreate.ts b/packages/ui/src/lib/worktrees/worktreeCreate.ts index a3a6dcd2..52890f8f 100644 --- a/packages/ui/src/lib/worktrees/worktreeCreate.ts +++ b/packages/ui/src/lib/worktrees/worktreeCreate.ts @@ -1,10 +1,7 @@ -import { toast } from '@/components/ui'; -import { getGitBranches, getGitStatus, gitFetch } from '@/lib/gitApi'; -import { formatMessage, useI18nStore } from '@/lib/i18n'; +import { getGitBranches, getGitStatus } from '@/lib/gitApi'; import type { CreateWorktreeArgs, ProjectRef } from '@/lib/worktrees/worktreeManager'; import { createWorktree } from '@/lib/worktrees/worktreeManager'; import { getRootBranch, resolveProjectRoot } from '@/lib/worktrees/worktreeStatus'; -import { useConfigStore } from '@/stores/useConfigStore'; const parseTrackingRef = (tracking: string | null | undefined): { remote: string; branch: string } | null => { const value = String(tracking || '').trim().replace(/^remotes\//, ''); @@ -122,17 +119,13 @@ const normalizeLocalBranchName = (value: string): string => { .replace(/^refs\/heads\//, ''); }; -export const withWorktreeFetchedStartRef = async ( +export const withWorktreeRemoteStartRef = async ( project: ProjectRef, args: CreateWorktreeArgs ): Promise => { if (args.mode === 'existing') { return args; } - if (useConfigStore.getState().settingsWorktreeFetchSource === false) { - return args; - } - const rawStartRef = String(args.startRef || '').trim(); if (rawStartRef && rawStartRef !== 'HEAD') { if (REMOTE_START_REF_PATTERN.test(rawStartRef) || COMMIT_SHA_PATTERN.test(rawStartRef)) { @@ -161,22 +154,6 @@ export const withWorktreeFetchedStartRef = async ( return args; } - const fallbackToLocalSource = () => { - toast.warning( - formatMessage(useI18nStore.getState().dictionary, 'session.newWorktree.toast.fetchSourceFailed'), - ); - return args; - }; - - try { - const fetchResult = await gitFetch(projectDirectory, { remote: tracking.remote, branch: tracking.branch }); - if (fetchResult.success !== true) { - return fallbackToLocalSource(); - } - } catch { - return fallbackToLocalSource(); - } - return { ...args, startRef: `remotes/${tracking.remote}/${tracking.branch}` }; }; @@ -185,7 +162,7 @@ export const createWorktreeWithDefaults = async ( args: CreateWorktreeArgs, options?: { resolvedRootTrackingRemote?: string | null } ) => { - const fetchedArgs = await withWorktreeFetchedStartRef(project, args); - const resolvedArgs = await withWorktreeUpstreamDefaults(project.path, fetchedArgs, options); + const remoteArgs = await withWorktreeRemoteStartRef(project, args); + const resolvedArgs = await withWorktreeUpstreamDefaults(project.path, remoteArgs, options); return createWorktree(project, resolvedArgs); }; diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index 6a8698cc..109fac8b 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -26,6 +26,7 @@ const createdWorktree = { let createdWorktreeResult: GitWorktreeCreateResult = createdWorktree; const bootstrapWatcherCalls: string[] = []; const bootstrapWatcherOptions: Array<{ onReady?: () => void }> = []; +const warningToasts: string[] = []; const sessionState = { availableWorktreesByProject: new Map(), @@ -40,6 +41,17 @@ mock.module('@/lib/openchamberConfig', () => ({ substituteCommandVariables: (command: string) => command, })); +mock.module('@/components/ui', () => ({ + toast: { + warning: (message: string) => warningToasts.push(message), + }, +})); + +mock.module('@/lib/i18n', () => ({ + formatMessage: () => 'session.newWorktree.toast.fetchSourceFailed', + useI18nStore: { getState: () => ({ dictionary: {} }) }, +})); + mock.module('@/lib/worktrees/worktreeBootstrap', () => ({ clearWorktreeBootstrapState: mock(), markWorktreeBootstrapPending: mock(), @@ -130,6 +142,7 @@ describe('worktreeManager list invalidation', () => { validatePayloads.length = 0; bootstrapWatcherCalls.length = 0; bootstrapWatcherOptions.length = 0; + warningToasts.length = 0; createdWorktreeResult = createdWorktree; sessionState.availableWorktreesByProject = new Map(); sessionState.availableWorktrees = []; @@ -137,6 +150,17 @@ describe('worktreeManager list invalidation', () => { attachmentState.attachments = new Map(); }); + test('warns when worktree creation falls back after a source fetch failure', async () => { + createdWorktreeResult = { ...createdWorktree, sourceFetchFailed: true }; + + await createWorktree({ id: 'project-fetch-fallback', path: '/repo' }, { + branchName: 'feature', + worktreeName: 'feature', + }); + + expect(warningToasts).toEqual(['session.newWorktree.toast.fetchSourceFailed']); + }); + test('retries an in-flight list when a worktree is created before it resolves', async () => { const project = { id: 'project-1', path: '/repo' }; const listing = listProjectWorktrees(project); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 67cf5235..b14de347 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -1,4 +1,6 @@ import { substituteCommandVariables } from '@/lib/openchamberConfig'; +import { toast } from '@/components/ui'; +import { formatMessage, useI18nStore } from '@/lib/i18n'; import type { WorktreeMetadata } from '@/types/worktree'; import { deleteRemoteBranch, @@ -58,6 +60,10 @@ const normalizePath = (value: string): string => { return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced; }; +/** The name the sidebar shows for a worktree, used in worktree-scoped toasts. */ +export const getWorktreeDisplayName = (worktree: WorktreeMetadata): string => + worktree.branch || worktree.label || worktree.path; + export const getLatestWorktreeMetadata = (metadata: WorktreeMetadata): WorktreeMetadata => { const target = normalizePath(metadata.path); const state = useSessionUIStore.getState(); @@ -508,6 +514,11 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr const payload = toCreatePayload(args, projectDirectory); const created = await git.worktree.create(projectDirectory, payload); + if (created?.sourceFetchFailed) { + toast.warning( + formatMessage(useI18nStore.getState().dictionary, 'session.newWorktree.toast.fetchSourceFailed'), + ); + } const returnedName = typeof created?.name === 'string' ? created.name : ''; const returnedBranch = typeof created?.branch === 'string' ? created.branch : ''; const returnedPath = typeof created?.path === 'string' ? created.path : ''; diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index e061a64c..66f1487e 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -56,7 +56,6 @@ interface OpenChamberDefaults { defaultVariant?: string; defaultAgent?: string; autoCreateWorktree?: boolean; - worktreeFetchSource?: boolean; gitmojiEnabled?: boolean; defaultFileViewerPreview?: boolean; zenModel?: string; @@ -129,7 +128,6 @@ const requestOpenChamberDefaults = async (): Promise => { defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined, defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined, autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, - worktreeFetchSource: typeof data?.worktreeFetchSource === 'boolean' ? data.worktreeFetchSource : undefined, gitmojiEnabled, defaultFileViewerPreview, zenModel: zenModel.length > 0 ? zenModel : undefined, @@ -176,7 +174,6 @@ const requestOpenChamberDefaults = async (): Promise => { defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined, defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined, autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, - worktreeFetchSource: typeof data?.worktreeFetchSource === 'boolean' ? data.worktreeFetchSource : undefined, gitmojiEnabled, defaultFileViewerPreview, zenModel: zenModel.length > 0 ? zenModel : undefined, @@ -1056,7 +1053,6 @@ interface ConfigStore { // when neither our settingsDefaultModel nor the resolved agent pins a model. opencodeDefaultModel: string | undefined; settingsAutoCreateWorktree: boolean; - settingsWorktreeFetchSource: boolean; settingsGitmojiEnabled: boolean; settingsDefaultFileViewerPreview: boolean; settingsZenModel: string | undefined; @@ -1144,7 +1140,6 @@ interface ConfigStore { setSettingsDefaultVariant: (variant: string | undefined) => void; setSettingsDefaultAgent: (agent: string | undefined) => void; setSettingsAutoCreateWorktree: (enabled: boolean) => void; - setSettingsWorktreeFetchSource: (enabled: boolean) => void; setSettingsGitmojiEnabled: (enabled: boolean) => void; setSettingsDefaultFileViewerPreview: (enabled: boolean) => void; setSettingsZenModel: (model: string | undefined) => void; @@ -1226,7 +1221,6 @@ export const useConfigStore = create()( opencodeDefaultAgent: undefined, opencodeDefaultModel: undefined, settingsAutoCreateWorktree: false, - settingsWorktreeFetchSource: true, settingsGitmojiEnabled: false, settingsDefaultFileViewerPreview: false, settingsZenModel: undefined, @@ -2171,7 +2165,6 @@ export const useConfigStore = create()( settingsDefaultVariant: openChamberDefaults.defaultVariant, settingsDefaultAgent: openChamberDefaults.defaultAgent, settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false, - settingsWorktreeFetchSource: openChamberDefaults.worktreeFetchSource ?? true, settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false, settingsDefaultFileViewerPreview: openChamberDefaults.defaultFileViewerPreview ?? false, settingsZenModel: resolvedZenModel, @@ -2921,10 +2914,6 @@ export const useConfigStore = create()( set({ settingsAutoCreateWorktree: enabled }); }, - setSettingsWorktreeFetchSource: (enabled: boolean) => { - set({ settingsWorktreeFetchSource: enabled }); - }, - setSettingsGitmojiEnabled: (enabled: boolean) => { set({ settingsGitmojiEnabled: enabled }); }, @@ -3467,7 +3456,6 @@ export const useConfigStore = create()( settingsDefaultVariant: state.settingsDefaultVariant, settingsDefaultAgent: state.settingsDefaultAgent, settingsAutoCreateWorktree: state.settingsAutoCreateWorktree, - settingsWorktreeFetchSource: state.settingsWorktreeFetchSource, settingsGitmojiEnabled: state.settingsGitmojiEnabled, settingsDefaultFileViewerPreview: state.settingsDefaultFileViewerPreview, settingsZenModel: state.settingsZenModel, diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 35029076..2db47dea 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -23,6 +23,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t - `gitService.ts` - Owns VS Code Git and worktree operations. + - Fetches the current tracked source branch once before worktree creation. Fetch failure falls back to the local branch and reports it to the shared UI. - Fast worktree creation reports bootstrap phases explicitly: `directory-created`, then `git-ready` after Git population/upstream work, and `setup-ready` after setup commands. Existing worktrees without tracked bootstrap state fall back to `ready`/`setup-ready`; shared webview consumers also accept legacy responses without `phase`. - Worktree removal waits for an active create/bootstrap task for the same directory so background Git and setup work cannot race deletion or restore stale bootstrap state. - Worktree population enables Git `core.longpaths` (local repo config plus `-c core.longpaths=true` on `git reset --hard`) so deeply nested checkouts under the managed data-dir worktree root do not fail on Windows MAX_PATH with "Filename too long". diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index b1fa8db6..ce9bde2c 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -860,6 +860,7 @@ export interface GitWorktreeInfo { path: string; directoryCreated?: true; bootstrapStatus?: WorktreeBootstrapStatus; + sourceFetchFailed?: true; } type WorktreeListEntry = { @@ -1987,6 +1988,7 @@ async function attachGitWorktreeToCandidate( const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); if (parsedRemoteStartRef) { + worktreeAddArgs.splice(2, 0, '--no-track'); inferredUpstream = { remote: parsedRemoteStartRef.remote, branch: parsedRemoteStartRef.branch, @@ -1994,28 +1996,10 @@ async function attachGitWorktreeToCandidate( } } - if (ensureRemoteName && ensureRemoteUrl) { + if (mode === 'existing' && ensureRemoteName && ensureRemoteUrl) { await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); } - if (mode === 'new') { - const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); - if (parsedRemoteStartRef) { - try { - await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch); - } catch (error) { - const refExists = await runGitCommand( - context.primaryWorktree, - ['show-ref', '--verify', '--quiet', parsedRemoteStartRef.fullRef], - ); - if (!refExists.success) { - throw error; - } - console.warn(`[GitService] failed to refresh ${parsedRemoteStartRef.remote}/${parsedRemoteStartRef.branch}, proceeding with the existing remote-tracking ref`); - } - } - } - await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree'); const shouldSetUpstream = Boolean(input?.setUpstream); @@ -2059,6 +2043,59 @@ async function attachGitWorktreeToCandidate( }; } +const prepareWorktreeCreateSource = async ( + context: Awaited>, + input: CreateGitWorktreePayload, +): Promise<{ input: CreateGitWorktreePayload; sourceFetchFailed: boolean }> => { + if (input.mode === 'existing') { + return { input, sourceFetchFailed: false }; + } + + const ensureRemoteName = String(input.ensureRemoteName || '').trim(); + const ensureRemoteUrl = String(input.ensureRemoteUrl || '').trim(); + if (ensureRemoteName && ensureRemoteUrl) { + await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); + } + + const startRef = normalizeStartRef(input.startRef); + const remoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); + if (!remoteStartRef) { + return { input, sourceFetchFailed: false }; + } + + const status = await getGitStatus(context.primaryWorktree, { mode: 'light' }).catch(() => null); + const trackingRef = status?.tracking + ? await resolveRemoteBranchRef(context.primaryWorktree, status.tracking) + : null; + const canFallbackToLocal = Boolean( + status?.current + && status.ahead === 0 + && trackingRef?.fullRef === remoteStartRef.fullRef + ); + + try { + await fetchRemoteBranchRef(context.primaryWorktree, remoteStartRef.remote, remoteStartRef.branch); + return { input, sourceFetchFailed: false }; + } catch (error) { + if (canFallbackToLocal && status?.current) { + return { + input: { ...input, startRef: status.current }, + sourceFetchFailed: true, + }; + } + + const refExists = await runGitCommand( + context.primaryWorktree, + ['show-ref', '--verify', '--quiet', remoteStartRef.fullRef], + ); + if (!refExists.success) { + throw error; + } + console.warn(`[GitService] failed to refresh ${remoteStartRef.remote}/${remoteStartRef.branch}, proceeding with the existing remote-tracking ref`); + return { input, sourceFetchFailed: false }; + } +}; + export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); @@ -2066,11 +2103,13 @@ export async function createWorktree(directory: string, input: CreateGitWorktree if (input?.returnAfterDirectoryCreated === true) { await assertWorktreeCreatePreflight(directory, input); } + const prepared = await prepareWorktreeCreateSource(context, input); + const preparedInput = prepared.input; await fs.promises.mkdir(context.worktreeRoot, { recursive: true }); - const preferredName = String(input?.worktreeName || input?.name || '').trim(); - const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); + const preferredName = String(preparedInput.worktreeName || preparedInput.name || '').trim(); + const preferredBranchName = cleanBranchName(String(preparedInput.branchName || '').trim()); const candidate = await resolveCandidateDirectory( context.worktreeRoot, @@ -2079,7 +2118,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree context.primaryWorktree ); - if (input?.returnAfterDirectoryCreated === true) { + if (preparedInput.returnAfterDirectoryCreated === true) { await fs.promises.mkdir(candidate.directory, { recursive: false }); const bootstrapStatus = setWorktreeBootstrapState( @@ -2093,17 +2132,17 @@ export async function createWorktree(directory: string, input: CreateGitWorktree updatedAt: Date.now(), }; const localBranch = mode === 'existing' - ? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim()) + ? cleanBranchName(String(preparedInput.branchName || preparedInput.existingBranch || candidate.branch || '').trim()) : candidate.branch; - const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => { + const task = attachGitWorktreeToCandidate(context, candidate, preparedInput).catch(async (error) => { setWorktreeBootstrapFailure(candidate.directory, error); await cleanupFailedFastWorktreeCreate(context, candidate); console.warn('[GitService] Background worktree creation failed:', error instanceof Error ? error.message : String(error)); }); trackWorktreeBootstrapTask(candidate.directory, task); - return { + const result: GitWorktreeInfo = { head: '', name: candidate.name, branch: localBranch, @@ -2111,9 +2150,14 @@ export async function createWorktree(directory: string, input: CreateGitWorktree directoryCreated: true, bootstrapStatus, }; + if (prepared.sourceFetchFailed) { + result.sourceFetchFailed = true; + } + return result; } - return attachGitWorktreeToCandidate(context, candidate, input); + const result = await attachGitWorktreeToCandidate(context, candidate, preparedInput); + return prepared.sourceFetchFailed ? { ...result, sourceFetchFailed: true } : result; } export async function getWorktreeBootstrapStatus(directory: string): Promise { diff --git a/packages/vscode/src/gitService.worktree-fetch-fallback.test.js b/packages/vscode/src/gitService.worktree-fetch-fallback.test.js index a801de9b..d6fff585 100644 --- a/packages/vscode/src/gitService.worktree-fetch-fallback.test.js +++ b/packages/vscode/src/gitService.worktree-fetch-fallback.test.js @@ -58,7 +58,7 @@ afterEach(() => { }); describe('VS Code worktree create from a remote start ref', () => { - it('creates from the existing remote-tracking ref when the refresh fetch fails', async () => { + it('falls back to the tracked local branch when the source fetch fails', async () => { if (!canRunGit()) return; const previousXdgDataHome = process.env.XDG_DATA_HOME; @@ -66,6 +66,7 @@ describe('VS Code worktree create from a remote start ref', () => { try { const { repository } = createRepositoryWithRemote(); + runGit(repository, ['branch', '--set-upstream-to=origin/main', 'next']); runGit(repository, ['remote', 'set-url', 'origin', '/nonexistent/openchamber-unreachable.git']); const created = await createWorktree(repository, { @@ -76,7 +77,8 @@ describe('VS Code worktree create from a remote start ref', () => { }); expect(created.branch).toBe('openchamber/stale-ref-wt'); - const expectedHead = runGit(repository, ['rev-parse', 'refs/remotes/origin/main']).trim(); + expect(created.sourceFetchFailed).toBe(true); + const expectedHead = runGit(repository, ['rev-parse', 'next']).trim(); expect(runGit(created.path, ['rev-parse', 'HEAD']).trim()).toBe(expectedHead); } finally { if (previousXdgDataHome === undefined) { diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index f328dbb3..8403627f 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -49,7 +49,7 @@ The following functions are exported and used by the web server: ### Worktree Operations - `getWorktrees(directory)`: List all git worktrees for a repository. - `validateWorktreeCreate(directory, input)`: Validate worktree creation parameters (mode, branchName, startRef, upstream config). -- `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup). After populating the worktree, the repository's `post-checkout` hook runs once with git's standard arguments (null ref as previous HEAD, the checked-out HEAD, and flag `1`) from the worktree directory, mirroring `git worktree add` without `--no-checkout`; a missing or non-executable hook is skipped and a failing hook is logged as a warning, never failing worktree creation or the session bootstrap. +- `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup). When the current tracked branch has no unpublished commits, the UI supplies its remote-tracking ref and this operation fetches that branch once before creating the worktree. A failed fetch falls back to the local branch and reports `sourceFetchFailed`; other remote start refs still require an existing local ref when their fetch fails. After populating the worktree, the repository's `post-checkout` hook runs once with git's standard arguments (null ref as previous HEAD, the checked-out HEAD, and flag `1`) from the worktree directory, mirroring `git worktree add` without `--no-checkout`; a missing or non-executable hook is skipped and a failing hook is logged as a warning, never failing worktree creation or the session bootstrap. - `removeWorktree(directory, input)`: Remove a worktree (optionally delete local branch). - `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary). @@ -143,6 +143,7 @@ The following functions are internal helpers used by exported functions: - `path`: Absolute path to worktree directory. - `directoryCreated`: Present when create returned after the target directory exists while background Git/bootstrap work continues. - `bootstrapStatus`: Background setup state. The legacy `status` remains `pending`, `ready`, or `failed`, while `phase` reports `directory-created`, `git-ready`, or `setup-ready`. Fast create starts at `pending`/`directory-created`; population and upstream Git completion advances to `pending`/`git-ready` before setup/start scripts; completed setup is `ready`/`setup-ready`. A missing in-memory state falls back to `ready`/`setup-ready`; clients continue to accept legacy status responses that omit `phase`. +- `sourceFetchFailed`: Present when the automatic source-branch fetch failed and creation fell back to the tracked local branch. - Fast-create background failures remove OpenCode sandbox metadata for directories that never became Git worktrees, and remove the pre-created directory only if it is still empty. User-created files are never recursively deleted by this cleanup. - Worktree removal waits for any active create/bootstrap task for that directory before deleting it, preventing a background Git or setup task from restoring removed state or racing filesystem cleanup. - Worktree bootstrap retries transient `index.lock` conflicts. If the lock remains byte-for-byte and metadata-identical across the retry window, it is treated as stale, removed, and population continues automatically; changing locks are left untouched and reported as failures. diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 8abfe29a..df0607ae 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -4303,28 +4303,10 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) { } } - if (ensureRemoteName && ensureRemoteUrl) { + if (mode === 'existing' && ensureRemoteName && ensureRemoteUrl) { await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); } - if (mode === 'new') { - const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); - if (parsedRemoteStartRef) { - try { - await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch); - } catch (error) { - const refExists = await runGitCommand( - context.primaryWorktree, - ['show-ref', '--verify', '--quiet', parsedRemoteStartRef.fullRef] - ); - if (!refExists.success) { - throw error; - } - console.warn(`Worktree create: failed to refresh ${parsedRemoteStartRef.remote}/${parsedRemoteStartRef.branch}, proceeding with the existing remote-tracking ref`); - } - } - } - await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree'); const upstreamRemote = shouldSetUpstream @@ -4366,6 +4348,50 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) { }; } +const prepareWorktreeCreateSource = async (context, input = {}) => { + if (input?.mode === 'existing') { + return { input, sourceFetchFailed: false }; + } + + const startRef = normalizeStartRef(input?.startRef); + const remoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef); + if (!remoteStartRef) { + return { input, sourceFetchFailed: false }; + } + + const status = await getStatus(context.primaryWorktree, { mode: 'light' }).catch(() => null); + const trackingRef = status?.tracking + ? await resolveRemoteBranchRef(context.primaryWorktree, status.tracking) + : null; + const canFallbackToLocal = Boolean( + status?.current + && status.ahead === 0 + && trackingRef?.fullRef === remoteStartRef.fullRef + ); + + try { + await fetchRemoteBranchRef(context.primaryWorktree, remoteStartRef.remote, remoteStartRef.branch); + return { input, sourceFetchFailed: false }; + } catch (error) { + if (canFallbackToLocal) { + return { + input: { ...input, startRef: status.current }, + sourceFetchFailed: true, + }; + } + + const refExists = await runGitCommand( + context.primaryWorktree, + ['show-ref', '--verify', '--quiet', remoteStartRef.fullRef] + ); + if (!refExists.success) { + throw error; + } + console.warn(`Worktree create: failed to refresh ${remoteStartRef.remote}/${remoteStartRef.branch}, proceeding with the existing remote-tracking ref`); + return { input, sourceFetchFailed: false }; + } +}; + export async function createWorktree(directory, input = {}) { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); @@ -4374,10 +4400,18 @@ export async function createWorktree(directory, input = {}) { await assertWorktreeCreatePreflight(directory, input); } + const ensureRemoteName = String(input?.ensureRemoteName || '').trim(); + const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); + if (ensureRemoteName && ensureRemoteUrl) { + await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); + } + const prepared = await prepareWorktreeCreateSource(context, input); + const preparedInput = prepared.input; + await fsp.mkdir(context.worktreeRoot, { recursive: true }); - const preferredName = String(input?.worktreeName || input?.name || '').trim(); - const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); + const preferredName = String(preparedInput?.worktreeName || preparedInput?.name || '').trim(); + const preferredBranchName = cleanBranchName(String(preparedInput?.branchName || '').trim()); const candidate = await resolveCandidateDirectory( context.worktreeRoot, @@ -4386,7 +4420,7 @@ export async function createWorktree(directory, input = {}) { context.primaryWorktree ); - if (input?.returnAfterDirectoryCreated === true) { + if (preparedInput?.returnAfterDirectoryCreated === true) { await fsp.mkdir(candidate.directory, { recursive: false }); const bootstrapStatus = setWorktreeBootstrapState( @@ -4395,10 +4429,10 @@ export async function createWorktree(directory, input = {}) { WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED ); const localBranch = mode === 'existing' - ? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim()) + ? cleanBranchName(String(preparedInput?.branchName || preparedInput?.existingBranch || candidate.branch || '').trim()) : candidate.branch; - const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => { + const task = attachGitWorktreeToCandidate(context, candidate, preparedInput).catch(async (error) => { setWorktreeBootstrapState( candidate.directory, WORKTREE_BOOTSTRAP_FAILED, @@ -4410,7 +4444,7 @@ export async function createWorktree(directory, input = {}) { }); trackWorktreeBootstrapTask(candidate.directory, task); - return { + const result = { head: '', name: candidate.name, branch: localBranch, @@ -4418,9 +4452,14 @@ export async function createWorktree(directory, input = {}) { directoryCreated: true, bootstrapStatus, }; + if (prepared.sourceFetchFailed) { + result.sourceFetchFailed = true; + } + return result; } - return attachGitWorktreeToCandidate(context, candidate, input); + const result = await attachGitWorktreeToCandidate(context, candidate, preparedInput); + return prepared.sourceFetchFailed ? { ...result, sourceFetchFailed: true } : result; } export async function getWorktreeBootstrapStatus(directory) { diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 00356448..b5921050 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -934,7 +934,7 @@ describe('createWorktree', () => { } }, 30_000); - it('creates from a remote start ref when the refresh fetch fails but the ref exists locally', async () => { + it('falls back to the tracked local branch when the source fetch fails', async () => { if (!canRunGit()) return; const previousXdgDataHome = process.env.XDG_DATA_HOME; @@ -943,6 +943,7 @@ describe('createWorktree', () => { try { const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' }); + runGit(repository, ['branch', '--set-upstream-to=origin/main', 'next']); runGit(repository, ['remote', 'set-url', 'origin', '/nonexistent/openchamber-unreachable.git']); const created = await createWorktree(repository, { @@ -953,7 +954,8 @@ describe('createWorktree', () => { }); expect(created.branch).toBe('openchamber/stale-ref-wt'); - const expectedHead = runGit(repository, ['rev-parse', 'refs/remotes/origin/main']).trim(); + expect(created.sourceFetchFailed).toBe(true); + const expectedHead = runGit(repository, ['rev-parse', 'next']).trim(); expect(runGit(created.path, ['rev-parse', 'HEAD']).trim()).toBe(expectedHead); } finally { if (previousXdgDataHome === undefined) { diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 5b135dff..68b7aa85 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -480,9 +480,6 @@ export const createSettingsHelpers = (dependencies) => { if (typeof candidate.autoCreateWorktree === 'boolean') { result.autoCreateWorktree = candidate.autoCreateWorktree; } - if (typeof candidate.worktreeFetchSource === 'boolean') { - result.worktreeFetchSource = candidate.worktreeFetchSource; - } if (typeof candidate.gitmojiEnabled === 'boolean') { result.gitmojiEnabled = candidate.gitmojiEnabled; } From 0ef189f3c8455987b29d9119e71ebb715c237a2c Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Fri, 4 Sep 2026 16:50:03 +0300 Subject: [PATCH 2/2] perf(sessions): archive a worktree's sessions through one server batch Removing a worktree archived its sessions one SDK call at a time and then re-rendered the whole sidebar once per streamed session.updated echo. On a worktree with 121 sessions that meant 14.8s of main-thread work, 121 requests, and 328 localStorage writes. - Add POST /api/openchamber/sessions/archive: validates a batch (max 500 ids, per-request archivedAt), archives sequentially, and reports partial failures instead of dropping the batch. VS Code serves no such route and answers 501; the shared UI then falls back to the per-session path. - Plan batches from the sessions this client actually holds, live directory stores first, so worktree-only sessions still batch. - Claim (id, archivedAt) pairs before the request and consume the matching session.updated echoes, so the server's own confirmations no longer fan out into 121 store publications. Runtime-scoped, TTL 30s, released on response or fallback; non-matching updates pass. - Make the managed-chats persistence a real trailing debounce instead of a 50ms throttle, so a burst of publications coalesces into one localStorage write. Benchmark (121 sessions, production build, real Chrome): 14785ms -> ~1030ms, long tasks 100 -> 1, global store publications 236 -> 1, persistence writes 328 -> 3. --- .../sync-context-session-events.test.ts | 41 +++- packages/ui/src/sync/bulk-archive-echo.ts | 49 ++++ packages/ui/src/sync/persist-cache.test.ts | 10 + packages/ui/src/sync/persist-cache.ts | 2 +- packages/ui/src/sync/session-actions.test.ts | 186 ++++++++++++++- packages/ui/src/sync/session-actions.ts | 213 +++++++++++++++++- packages/ui/src/sync/session-archive-batch.ts | 77 +++++++ packages/ui/src/sync/sync-context.tsx | 8 +- packages/vscode/webview/main.tsx | 8 + .../server/lib/openchamber-sessions/routes.js | 102 +++++++++ .../lib/openchamber-sessions/routes.test.js | 88 ++++++++ 11 files changed, 769 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/sync/bulk-archive-echo.ts create mode 100644 packages/ui/src/sync/session-archive-batch.ts diff --git a/packages/ui/src/sync/__tests__/sync-context-session-events.test.ts b/packages/ui/src/sync/__tests__/sync-context-session-events.test.ts index d846e868..5a42c6c5 100644 --- a/packages/ui/src/sync/__tests__/sync-context-session-events.test.ts +++ b/packages/ui/src/sync/__tests__/sync-context-session-events.test.ts @@ -45,7 +45,15 @@ mock.module("@/lib/runtime-switch", () => ({ return () => undefined }, })) -import { applySessionEventsToGlobalSessions, applySessionEventToGlobalSessions } from "../session-event-router" +import { + applySessionEventsToGlobalSessions, + applySessionEventToGlobalSessions, +} from "../session-event-router" +import { + registerBulkArchiveEchoes, + releaseBulkArchiveEchoes, + shouldConsumeBulkArchiveEcho, +} from "../bulk-archive-echo" const buildSession = (title: string, time: Session["time"]): Session => ({ id: "ses_1", @@ -146,4 +154,35 @@ describe("applySessionEventToGlobalSessions", () => { expect(mutationCalls).toBe(1) expect(upsertedSessions).toHaveLength(1_000) }) + + test("consumes only the matching bulk archive echo", () => { + registerBulkArchiveEchoes(runtimeKey, [{ id: "ses_1", archivedAt: 20 }], 100) + + expect(shouldConsumeBulkArchiveEcho(buildEvent(buildSession("Initial", { + created: 1, + updated: 20, + archived: 20, + })), runtimeKey, 101)).toBe(true) + expect(shouldConsumeBulkArchiveEcho(buildEvent(buildSession("Initial", { + created: 1, + updated: 21, + archived: 21, + })), runtimeKey, 101)).toBe(false) + expect(shouldConsumeBulkArchiveEcho(buildEvent(buildSession("Initial", { + created: 1, + updated: 20, + archived: 20, + })), "runtime-b", 101)).toBe(false) + }) + + test("does not consume an expired or released bulk archive echo", () => { + registerBulkArchiveEchoes(runtimeKey, [{ id: "ses_1", archivedAt: 20 }], 100) + const event = buildEvent(buildSession("Initial", { created: 1, updated: 20, archived: 20 })) + + expect(shouldConsumeBulkArchiveEcho(event, runtimeKey, 30_101)).toBe(false) + + registerBulkArchiveEchoes(runtimeKey, [{ id: "ses_1", archivedAt: 20 }], 100) + releaseBulkArchiveEchoes(runtimeKey, ["ses_1"]) + expect(shouldConsumeBulkArchiveEcho(event, runtimeKey, 101)).toBe(false) + }) }) diff --git a/packages/ui/src/sync/bulk-archive-echo.ts b/packages/ui/src/sync/bulk-archive-echo.ts new file mode 100644 index 00000000..0e9f469e --- /dev/null +++ b/packages/ui/src/sync/bulk-archive-echo.ts @@ -0,0 +1,49 @@ +import type { Event } from "@opencode-ai/sdk/v2/client" +import { subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch" + +const BULK_ARCHIVE_ECHO_TTL_MS = 30_000 +const pendingEchoes = new Map>() + +subscribeRuntimeEndpointWillChange(() => pendingEchoes.clear()) + +export const registerBulkArchiveEchoes = ( + runtimeKey: string, + sessions: Iterable<{ id: string; archivedAt: number }>, + now = Date.now(), +): void => { + let runtimeEchoes = pendingEchoes.get(runtimeKey) + if (!runtimeEchoes) { + runtimeEchoes = new Map() + pendingEchoes.set(runtimeKey, runtimeEchoes) + } + for (const session of sessions) { + runtimeEchoes.set(session.id, { + archivedAt: session.archivedAt, + expiresAt: now + BULK_ARCHIVE_ECHO_TTL_MS, + }) + } +} + +export const releaseBulkArchiveEchoes = (runtimeKey: string, sessionIds: Iterable): void => { + const runtimeEchoes = pendingEchoes.get(runtimeKey) + if (!runtimeEchoes) return + for (const sessionId of sessionIds) runtimeEchoes.delete(sessionId) + if (runtimeEchoes.size === 0) pendingEchoes.delete(runtimeKey) +} + +export const shouldConsumeBulkArchiveEcho = ( + event: Event, + runtimeKey: string, + now = Date.now(), +): boolean => { + if (event.type !== "session.updated") return false + const runtimeEchoes = pendingEchoes.get(runtimeKey) + const expected = runtimeEchoes?.get(event.properties.info.id) + if (!expected) return false + if (expected.expiresAt < now) { + runtimeEchoes?.delete(event.properties.info.id) + if (runtimeEchoes?.size === 0) pendingEchoes.delete(runtimeKey) + return false + } + return event.properties.info.time.archived === expected.archivedAt +} diff --git a/packages/ui/src/sync/persist-cache.test.ts b/packages/ui/src/sync/persist-cache.test.ts index 2afc3889..95427e33 100644 --- a/packages/ui/src/sync/persist-cache.test.ts +++ b/packages/ui/src/sync/persist-cache.test.ts @@ -92,6 +92,16 @@ describe("persisted directory sessions", () => { expect(readManagedChatSessions()).toEqual([]) }) + test("coalesces a continuing burst into one trailing session write", async () => { + persistSessions(directory, [session(1, 1)]) + await new Promise((resolve) => setTimeout(resolve, 30)) + persistSessions(directory, [session(1, 2)]) + await waitForPersistence() + + expect(storage.writes).toBe(1) + expect(readDirCache(directory).sessions?.[0]?.time.updated).toBe(2) + }) + test("keeps the 50 most recently updated sessions across restart reads", async () => { const sessions = Array.from({ length: 60 }, (_, updated) => session(59 - updated, updated)) diff --git a/packages/ui/src/sync/persist-cache.ts b/packages/ui/src/sync/persist-cache.ts index 51d3e580..f2a43af5 100644 --- a/packages/ui/src/sync/persist-cache.ts +++ b/packages/ui/src/sync/persist-cache.ts @@ -177,7 +177,7 @@ function scheduleSessionCacheWrite(directory: string, sessions: Session[]): void if (pending.runtimeKey !== runtimeKey) pendingSessionWrites.delete(pendingKey) } pendingSessionWrites.set(key, { runtimeKey, key, legacyKey: legacyCacheKey(directory, "sessions"), sessions }) - if (pendingSessionWriteTimer !== undefined) return + if (pendingSessionWriteTimer !== undefined) clearTimeout(pendingSessionWriteTimer) pendingSessionWriteTimer = setTimeout(flushPendingSessionWrites, SESSION_PERSIST_DEBOUNCE_MS) } diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index d4611074..01e703a1 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -21,7 +21,16 @@ let sessionDeleteError: unknown | null = null let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null const globalUpsertedSessions: unknown[] = [] +const globalUpsertedSessionBatches: Session[][] = [] const globalRemovedSessionIds: string[] = [] +// Sessions this client is holding. `archiveSessions` reads them to decide which +// sessions can be archived by the server in one batch. +let globalActiveSessions: Session[] = [] +const archiveBatchRequests: Array<{ directory: string; ids: string[] }> = [] +let archiveBatchResponse: { status: number; body: unknown } = { + status: 404, + body: { error: 'not found' }, +} const deletedCleanupIdentities: Array<{ runtimeKey: string; directory: string; sessionId: string }> = [] const movedSessionDirectories: Array<{ sessionID: string; directory: string }> = [] @@ -243,11 +252,15 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({ }, useGlobalSessionsStore: { getState: () => ({ - activeSessions: [], + activeSessions: globalActiveSessions, archivedSessions: [], upsertSession: (session: unknown) => { globalUpsertedSessions.push(session) }, + upsertSessions: (sessions: Session[]) => { + globalUpsertedSessionBatches.push(sessions) + globalUpsertedSessions.push(...sessions) + }, removeSessions: (ids: Iterable) => { globalRemovedSessionIds.push(...ids) }, @@ -255,6 +268,18 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({ }, })) +mock.module("@/lib/runtime-fetch", () => ({ + runtimeFetch: async (path: string, init?: { body?: string }) => { + const payload = JSON.parse(String(init?.body ?? "{}")) + archiveBatchRequests.push({ directory: payload.directory, ids: payload.ids }) + void path + return new Response(JSON.stringify(archiveBatchResponse.body), { + status: archiveBatchResponse.status, + headers: { "content-type": "application/json" }, + }) + }, +})) + mock.module("./session-deletion-cleanup", () => ({ cleanupPersistedSessionState: (identity: { runtimeKey: string; directory: string; sessionId: string }) => { deletedCleanupIdentities.push(identity) @@ -396,6 +421,10 @@ describe("confirmed session removal", () => { sessionUpdateResult = {} beforeSessionUpdateResolve = null beforeSessionDeleteResolve = null + globalUpsertedSessionBatches.length = 0 + globalActiveSessions = [] + archiveBatchRequests.length = 0 + archiveBatchResponse = { status: 404, body: { error: 'not found' } } }) test("does not remove live or persisted state when delete fails", async () => { @@ -636,6 +665,161 @@ describe("confirmed session removal", () => { }) }) +describe("archiving a batch through the server", () => { + const liveSession = (id: string, metadata?: Record): Session => ({ + id, + directory: "/test/project", + time: { created: 1 }, + ...(metadata ? { metadata } : {}), + } as unknown as Session) + + const archivedSession = (id: string): Session => ({ + id, + directory: "/test/project", + time: { created: 1, archived: 2 }, + } as unknown as Session) + + beforeEach(() => { + replyCalls.length = 0 + globalUpsertedSessions.length = 0 + globalUpsertedSessionBatches.length = 0 + globalActiveSessions = [] + archiveBatchRequests.length = 0 + archiveBatchResponse = { status: 404, body: { error: "not found" } } + sessionUpdateResult = {} + beforeSessionUpdateResolve = null + }) + + test("archives held sessions in one request and reconciles the stores once", async () => { + globalActiveSessions = [liveSession("session-a"), liveSession("session-b")] + archiveBatchResponse = { + status: 200, + body: { archived: [archivedSession("session-a"), archivedSession("session-b")], failedIds: [] }, + } + const source = createStore({}, { session: [liveSession("session-a"), liveSession("session-b")] }) + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const result = await archiveSessions(["session-a", "session-b"]) + + expect(result).toEqual({ archivedIds: ["session-a", "session-b"], failedIds: [] }) + expect(archiveBatchRequests).toEqual([{ directory: "/test/project", ids: ["session-a", "session-b"] }]) + // The point of the batch: no per-session SDK call, and one store write for + // the whole set instead of one per session. + expect(replyCalls.filter((call) => call.method === "session.update")).toEqual([]) + expect(globalUpsertedSessionBatches).toHaveLength(1) + expect(source.getState().session).toEqual([]) + expect(source.getState().sessionRevision).toBe(1) + }) + + test("batches sessions held only by the live directory store", async () => { + archiveBatchResponse = { + status: 200, + body: { archived: [archivedSession("session-a")], failedIds: [] }, + } + const source = createStore({}, { session: [liveSession("session-a")] }) + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const result = await archiveSessions(["session-a"]) + + expect(result).toEqual({ archivedIds: ["session-a"], failedIds: [] }) + expect(archiveBatchRequests).toEqual([{ directory: "/test/project", ids: ["session-a"] }]) + expect(replyCalls.filter((call) => call.method === "session.update")).toEqual([]) + }) + + test("reports the sessions the server could not archive without losing the rest", async () => { + globalActiveSessions = [liveSession("session-a"), liveSession("session-b")] + archiveBatchResponse = { + status: 200, + body: { archived: [archivedSession("session-a")], failedIds: ["session-b"] }, + } + const source = createStore({}, { session: [liveSession("session-a"), liveSession("session-b")] }) + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const result = await archiveSessions(["session-a", "session-b"]) + + expect(result).toEqual({ archivedIds: ["session-a"], failedIds: ["session-b"] }) + expect(source.getState().session.map((item) => item.id)).toEqual(["session-b"]) + }) + + test("falls back to archiving one by one when the runtime does not serve the route", async () => { + globalActiveSessions = [liveSession("session-a"), liveSession("session-b")] + archiveBatchResponse = { status: 501, body: { error: "not supported in VS Code" } } + sessionUpdateResult = { data: archivedSession("session-a") } + const source = createStore({}, { session: [liveSession("session-a"), liveSession("session-b")] }) + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const result = await archiveSessions(["session-a", "session-b"]) + + expect(result).toEqual({ archivedIds: ["session-a", "session-b"], failedIds: [] }) + expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID)) + .toEqual(["session-a", "session-b"]) + expect(source.getState().session).toEqual([]) + }) + + test("treats a malformed batch answer as unavailable instead of as an empty success", async () => { + globalActiveSessions = [liveSession("session-a")] + archiveBatchResponse = { status: 200, body: { archived: [{ title: "no id" }], failedIds: [] } } + sessionUpdateResult = { data: archivedSession("session-a") } + const source = createStore({}, { session: [liveSession("session-a")] }) + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const result = await archiveSessions(["session-a"]) + + expect(result).toEqual({ archivedIds: ["session-a"], failedIds: [] }) + expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID)) + .toEqual(["session-a"]) + }) + + test("keeps review and btw sessions on the per-session path", async () => { + const review = liveSession("session-review", { openchamber: { kind: "review", originalSessionID: "session-parent" } }) + const parentWithFork = liveSession("session-parent", { openchamber: { btwSessionID: "session-fork" } }) + globalActiveSessions = [liveSession("session-plain"), review, parentWithFork] + archiveBatchResponse = { + status: 200, + body: { archived: [archivedSession("session-plain")], failedIds: [] }, + } + sessionUpdateResult = { data: archivedSession("session-review") } + const source = createStore({}, { session: [liveSession("session-plain"), review, parentWithFork] }) + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + await archiveSessions(["session-plain", "session-review", "session-parent"]) + + // Unlinking a partner rewrites another session's metadata, so those two + // never travel in the batch. + expect(archiveBatchRequests).toEqual([{ directory: "/test/project", ids: ["session-plain"] }]) + expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID)) + .toEqual(["session-review", "session-parent"]) + }) + + test("does not reconcile a batch answered after a runtime switch", async () => { + globalActiveSessions = [liveSession("session-a")] + archiveBatchResponse = { + status: 200, + body: { archived: [archivedSession("session-a")], failedIds: [] }, + } + const source = createStore({}, { session: [liveSession("session-a")] }) + const { getRuntimeKey, switchRuntimeEndpoint } = await import("../lib/runtime-switch") + switchRuntimeEndpoint({ apiBaseUrl: "http://archive-bulk-a.test", runtimeKey: "archive-bulk-a" }) + const capturedRuntimeKey = getRuntimeKey() + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const pending = archiveSessions(["session-a"], { expectedRuntimeKey: capturedRuntimeKey }) + switchRuntimeEndpoint({ apiBaseUrl: "http://archive-bulk-b.test", runtimeKey: "archive-bulk-b" }) + const result = await pending + + expect(result).toEqual({ archivedIds: [], failedIds: ["session-a"] }) + expect(source.getState().session.map((item) => item.id)).toEqual(["session-a"]) + expect(globalUpsertedSessionBatches).toEqual([]) + }) +}) + describe("session restore (unarchive)", () => { beforeEach(() => { replyCalls.length = 0 diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index c208e0cc..85ddbd37 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -33,6 +33,8 @@ import { getBtwOriginalSessionID, getBtwSessionID, isBtwSession, withoutBtwSessi import { withLinkedIssue, type LinkedIssue } from "@/lib/linkedIssues" import { getImperativeSessionMessageLoader } from "./session-message-loader" import { cleanupPersistedSessionState } from "./session-deletion-cleanup" +import { requestSessionArchiveBatch } from "./session-archive-batch" +import { registerBulkArchiveEchoes, releaseBulkArchiveEchoes } from "./bulk-archive-echo" import { getRuntimeKey } from "@/lib/runtime-switch" import { markAmbiguousTransportFailure } from "@/lib/relay/transport-error" import { getErrorStatus, isAmbiguousSendFailure } from "./send-failure-classification" @@ -75,20 +77,29 @@ let _optimisticAdd: ((input: OptimisticAddInput) => void) | null = null let _optimisticRemove: ((input: OptimisticRemoveInput) => void) | null = null let _optimisticConfirm: ((input: OptimisticConfirmInput) => void) | null = null -function sessionMutationPatch( +/** + * Revision patch for one or more sessions changing in the same store write. + * + * A batch bumps the revision once, because it is one state change: consumers + * compare revisions to decide whether their view of the list is stale, and a + * batch leaves them stale exactly once rather than once per session. + */ +function sessionsMutationPatch( state: ReturnType, - sessionId: string, + sessionIds: Iterable, deleted: boolean, ) { const revision = (state.sessionRevision ?? 0) + 1 const sessionEventRevision = { ...(state.sessionEventRevision ?? {}) } const sessionDeletedRevision = { ...(state.sessionDeletedRevision ?? {}) } - if (deleted) { - sessionDeletedRevision[sessionId] = revision - delete sessionEventRevision[sessionId] - } else { - sessionEventRevision[sessionId] = revision - delete sessionDeletedRevision[sessionId] + for (const sessionId of sessionIds) { + if (deleted) { + sessionDeletedRevision[sessionId] = revision + delete sessionEventRevision[sessionId] + } else { + sessionEventRevision[sessionId] = revision + delete sessionDeletedRevision[sessionId] + } } return { sessionListSource: "live" as const, @@ -98,6 +109,14 @@ function sessionMutationPatch( } } +function sessionMutationPatch( + state: ReturnType, + sessionId: string, + deleted: boolean, +) { + return sessionsMutationPatch(state, [sessionId], deleted) +} + function invalidateSessionLoads(sessionId: string, directories: Iterable): void { const loader = getImperativeSessionMessageLoader() if (!loader) return @@ -1039,6 +1058,50 @@ function removeSessionFromLiveStores(sessionId: string, preferredDirectory?: str return snapshots } +/** + * Remove a batch of server-confirmed sessions from every live child store. + * + * Each affected store is written once for the whole batch. Removing the + * sessions one at a time notified every subscriber — and therefore re-rendered + * the sidebar — once per session, which is what made archiving a worktree's + * sessions block the main thread for seconds. + */ +function removeSessionsFromLiveStores(sessionIds: Iterable, preferredDirectory?: string): SessionListSnapshot[] { + const ids = new Set(sessionIds) + if (!_childStores || ids.size === 0) return [] + + const snapshots: SessionListSnapshot[] = [] + const visited = new Set() + const candidates: Array<[string, DirectoryStoreApi]> = [] + + if (preferredDirectory) { + const preferredStore = _childStores.children.get(preferredDirectory) + if (preferredStore) { + candidates.push([preferredDirectory, preferredStore]) + visited.add(preferredDirectory) + } + } + + for (const entry of _childStores.children.entries()) { + if (visited.has(entry[0])) continue + candidates.push(entry) + } + + for (const [directory, store] of candidates) { + const current = store.getState() + const removed = current.session.filter((session) => ids.has(session.id)).map((session) => session.id) + if (removed.length === 0) continue + + snapshots.push({ directory }) + store.setState({ + session: current.session.filter((session) => !ids.has(session.id)), + ...sessionsMutationPatch(current, removed, true), + }) + } + + return snapshots +} + function cleanupSessionWorktreeMetadata(sessionId: string): void { useSessionUIStore.getState().setWorktreeMetadata(sessionId, null) } @@ -1252,7 +1315,14 @@ export type ArchiveSessionsOptions = { } /** - * Archive several sessions sequentially, preserving partial results. + * Archive several sessions, preserving partial results. + * + * Sessions that carry no review or btw link are archived by their directory's + * server in one request, and the whole answer is reconciled with a single store + * write. The remainder — review sessions, btw forks, sessions with an active + * btw fork, and any session this client does not hold — keep the per-session + * path, because unlinking a partner is UI-owned work that reads and rewrites + * another session's metadata. * * One failed session never blocks or erases the others: it is reported in * `failedIds` while the remaining IDs are still attempted. When @@ -1269,10 +1339,55 @@ export async function archiveSessions( const archivedIds: string[] = [] const failedIds: string[] = [] const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey() + if (ids.length === 0) return { archivedIds, failedIds } - for (const [index, id] of ids.entries()) { + const plan = planArchiveBatches(ids) + + for (const [directory, batchIds] of plan.batchesByDirectory) { if (isStaleRuntime(expectedRuntimeKey)) { - failedIds.push(...ids.slice(index)) + failedIds.push(...batchIds) + continue + } + + const archivedAt = Date.now() + registerBulkArchiveEchoes( + expectedRuntimeKey, + batchIds.map((id) => ({ id, archivedAt })), + ) + const result = await requestSessionArchiveBatch(directory, batchIds, archivedAt) + if (isStaleRuntime(expectedRuntimeKey)) { + failedIds.push(...batchIds) + continue + } + + if (result.outcome === "archived") { + releaseBulkArchiveEchoes(expectedRuntimeKey, batchIds) + registerBulkArchiveEchoes( + expectedRuntimeKey, + result.archived.flatMap((session) => ( + session.time?.archived === undefined + ? [] + : [{ id: session.id, archivedAt: session.time.archived }] + )), + ) + commitArchivedSessions(result.archived, directory) + archivedIds.push(...result.archived.map((session) => session.id)) + failedIds.push(...result.failedIds) + continue + } + + // The runtime does not serve the batch route, or its answer could not be + // trusted. Archiving each session individually is slower but reaches the + // same state, and re-archiving a session the server already archived writes + // the same field again. + console.warn("[session-actions] archive batch unavailable, archiving one by one", result.reason) + releaseBulkArchiveEchoes(expectedRuntimeKey, batchIds) + plan.individualIds.push(...batchIds) + } + + for (const [index, id] of plan.individualIds.entries()) { + if (isStaleRuntime(expectedRuntimeKey)) { + failedIds.push(...plan.individualIds.slice(index)) break } if (await archiveSession(id, expectedRuntimeKey)) archivedIds.push(id) @@ -1282,6 +1397,82 @@ export async function archiveSessions( return { archivedIds, failedIds } } +/** + * A session whose archive also has to rewrite another session's metadata. + * + * Review sessions and btw forks point at a parent that must be unlinked, and a + * parent with an active btw fork has to delete that fork. Those are + * read-modify-write pairs on a second session, so they stay on the per-session + * path instead of the server batch. + */ +function hasLinkedSessionCleanup(session: Session): boolean { + return isReviewSession(session) || isBtwSession(session) || Boolean(getBtwSessionID(session)) +} + +/** + * Split the requested IDs into per-directory server batches and the sessions + * that must be archived individually. + * + * Link classification reads this client's session records rather than + * refetching each session: those records are kept current by the same + * `session.updated` events that publish a link created anywhere else, so a + * fetch per session would buy no authority the store does not already have. + * A session this client does not hold is classified as individual, which + * restores the per-session fetch for exactly the cases where the store has + * nothing to say. + */ +function planArchiveBatches(ids: string[]) { + const global = useGlobalSessionsStore.getState() + const knownSessions = new Map() + for (const session of [...global.activeSessions, ...global.archivedSessions]) { + knownSessions.set(session.id, session) + } + for (const store of _childStores?.children.values() ?? []) { + for (const session of store.getState().session) knownSessions.set(session.id, session) + } + + const batchesByDirectory = new Map() + const individualIds: string[] = [] + + for (const id of ids) { + const session = knownSessions.get(id) + const directory = session + ? resolveGlobalSessionDirectory(session) ?? getSessionDirectory(id) + : undefined + if (!session || !directory || hasLinkedSessionCleanup(session)) { + individualIds.push(id) + continue + } + const batch = batchesByDirectory.get(directory) + if (batch) batch.push(id) + else batchesByDirectory.set(directory, [id]) + } + + return { batchesByDirectory, individualIds } +} + +/** + * Reconcile a server-confirmed archive batch with one write per store. + * + * This mirrors what `archiveSession` does for a single session — drop it from + * the live directory stores, invalidate its cached messages, move it to the + * archived bucket, and clear it if it was open — with the per-session store + * notifications collapsed into one. + */ +function commitArchivedSessions(sessions: Session[], directory: string): void { + if (sessions.length === 0) return + + const ids = sessions.map((session) => session.id) + const snapshots = removeSessionsFromLiveStores(ids, directory) + const directories = [...snapshots.map((snapshot) => snapshot.directory), directory] + for (const id of ids) invalidateSessionLoads(id, directories) + + useGlobalSessionsStore.getState().upsertSessions(sessions) + + const ui = useSessionUIStore.getState() + if (ui.currentSessionId && ids.includes(ui.currentSessionId)) ui.setCurrentSession(null) +} + /** * Sentinel written to `time.archived` when restoring a session. * diff --git a/packages/ui/src/sync/session-archive-batch.ts b/packages/ui/src/sync/session-archive-batch.ts new file mode 100644 index 00000000..7774a43e --- /dev/null +++ b/packages/ui/src/sync/session-archive-batch.ts @@ -0,0 +1,77 @@ +/** + * Server-side archive batch. + * + * Archiving the sessions linked to a worktree one request at a time is what + * made removing a worktree with many sessions take tens of seconds: every + * session cost its own round trip and its own store reconciliation. This asks + * the OpenChamber server to archive the whole batch next to OpenCode, so the + * browser spends one request and reconciles once. + * + * The route is an OpenChamber capability, not an OpenCode one. Runtimes that do + * not serve it (the VS Code webview has no server process) answer with a stable + * unsupported status, and callers fall back to archiving session by session. + */ + +import type { Session } from '@opencode-ai/sdk/v2/client'; +import { z } from 'zod'; + +import { runtimeFetch } from '@/lib/runtime-fetch'; + +/** + * The route answers with sessions OpenCode itself returned from + * `session.update`. Only the identity this layer routes on is asserted here; + * every other field is carried through to the stores exactly as the server + * sent it, the same as for any other session response. + */ +const archiveResponseSchema = z.object({ + archived: z.array(z.looseObject({ id: z.string().min(1) })), + failedIds: z.array(z.string().min(1)), +}); + +export type SessionArchiveBatchResult = + | { outcome: 'archived'; archived: Session[]; failedIds: string[] } + | { outcome: 'unavailable'; reason: string }; + +export async function requestSessionArchiveBatch( + directory: string, + ids: string[], + archivedAt: number, +): Promise { + let response: Response; + try { + response = await runtimeFetch('/api/openchamber/sessions/archive', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ directory, ids, archivedAt }), + }); + } catch (error) { + return { outcome: 'unavailable', reason: error instanceof Error ? error.message : 'archive request failed' }; + } + + if (!response.ok) { + return { outcome: 'unavailable', reason: `archive request failed with ${response.status}` }; + } + + let body: unknown; + try { + body = await response.json(); + } catch (error) { + return { outcome: 'unavailable', reason: error instanceof Error ? error.message : 'archive response was not JSON' }; + } + + const parsed = archiveResponseSchema.safeParse(body); + if (!parsed.success) { + // A body this layer cannot read is reported as unavailable rather than as + // an empty success, so a caller never mistakes "the response made no + // sense" for "nothing needed archiving" and drops the sessions. + return { outcome: 'unavailable', reason: `malformed archive response: ${parsed.error.issues[0]?.message ?? 'unknown shape'}` }; + } + + return { + outcome: 'archived', + // SAFETY: the schema guarantees the non-empty string `id` this layer keys + // on; the remaining fields are the server's own session payload. + archived: parsed.data.archived as Session[], + failedIds: parsed.data.failedIds, + }; +} diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index ed2772ed..158f340f 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -38,7 +38,11 @@ import { setSyncRefs, getAllSyncSessions } from "./sync-refs" import { useSessionUIStore } from "./session-ui-store" import { stripSessionDiffSnapshots } from "./sanitize" import { upsertSessionRecord } from "./session-records" -import { applySessionEventToGlobalSessions, applySessionEventsToGlobalSessions } from "./session-event-router" +import { + applySessionEventToGlobalSessions, + applySessionEventsToGlobalSessions, +} from "./session-event-router" +import { shouldConsumeBulkArchiveEcho } from "./bulk-archive-echo" import { syncDebug } from "./debug" import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery" import { messagesBefore } from "./message-ordering" @@ -1586,6 +1590,8 @@ export function handleEvent( return } + if (shouldConsumeBulkArchiveEcho(payload, expectedRuntimeKey)) return + const directory = resolveDirectoryFromRoutingIndex(routingIndex, rawDirectory, payload, childStores, batch) if (payload.type === "session.deleted" && expectedRuntimeKey === getRuntimeKey()) { diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 03e8b1d1..4a4bc409 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -384,6 +384,14 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R return unsupportedWebRouteResponse('Remote tunnel settings'); } + // Archiving a batch of sessions server-side needs an OpenChamber server + // process; the extension host has none. Answering explicitly keeps the + // shared UI on its per-session archive path instead of leaving the request + // to the generic proxy. + if (normalizedPathname === '/api/openchamber/sessions/archive') { + return unsupportedWebRouteResponse('Server-side session archiving'); + } + if (/^\/api\/projects\/[^/]+\/scheduled-tasks(?:\/[^/]+)?$/.test(normalizedPathname)) { return unsupportedWebRouteResponse('Scheduled tasks'); } diff --git a/packages/web/server/lib/openchamber-sessions/routes.js b/packages/web/server/lib/openchamber-sessions/routes.js index 6c58b333..b9b768e0 100644 --- a/packages/web/server/lib/openchamber-sessions/routes.js +++ b/packages/web/server/lib/openchamber-sessions/routes.js @@ -253,6 +253,40 @@ const latestCompletedAssistantMessageID = async ({ client, sessionID, directory return asNonEmptyString(latest?.id); }; +/** + * Upper bound on one archive batch. + * + * The batch is applied one session at a time against OpenCode, so an unbounded + * list would hold a request open for as long as the list is large. Callers with + * more sessions than this send several batches and keep their own partial + * results. + */ +const MAX_ARCHIVE_BATCH = 500; + +const parseArchiveRequest = (payload) => { + const rawIds = payload?.ids; + if (!Array.isArray(rawIds) || rawIds.length === 0) { + return { ok: false, error: 'ids must be a non-empty array of session ids' }; + } + if (rawIds.length > MAX_ARCHIVE_BATCH) { + return { ok: false, error: `ids must contain at most ${MAX_ARCHIVE_BATCH} session ids` }; + } + + const ids = []; + for (const value of rawIds) { + const id = asNonEmptyString(value); + if (!id) return { ok: false, error: 'ids must contain non-empty session ids' }; + ids.push(id); + } + + const archivedAt = payload?.archivedAt; + if (archivedAt !== undefined && (!Number.isSafeInteger(archivedAt) || archivedAt <= 0)) { + return { ok: false, error: 'archivedAt must be a positive integer timestamp' }; + } + + return { ok: true, ids, archivedAt: archivedAt ?? Date.now() }; +}; + const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated, sanitizeProjects, validateDirectoryPath }) => { const projectID = asNonEmptyString(payload?.projectId) || asNonEmptyString(payload?.projectID); if (projectID) { @@ -579,6 +613,64 @@ export const createOpenChamberSessionService = (dependencies) => { return { model, agent, variant, promptDispatched: true, dispatchedAsCommand: Boolean(resolvedCommand) }; }; + /** + * Archive a batch of sessions in one request. + * + * The UI archives every session linked to a worktree before removing it. + * Doing that from the browser costs one request per session plus a store + * reconciliation between each of them, which is what made deleting a + * worktree with many sessions take tens of seconds. Here the batch stays on + * the server, next to OpenCode, and the client reconciles once. + * + * Sessions are updated one at a time on purpose: they are archived against a + * single OpenCode instance, and a fan-out of concurrent writes would trade a + * UI stall for server event-loop starvation. One failed session never stops + * the batch — it is reported in `failedIds` while the rest still archive, so + * callers keep the partial-failure behaviour they already show. + */ + const archive = async (payload = {}) => { + const parsed = parseArchiveRequest(payload); + if (!parsed.ok) { + throw new OpenChamberControlError(parsed.error, 400); + } + + const resolvedDirectory = await resolveRequestedDirectory({ + payload, + readSettingsFromDiskMigrated, + sanitizeProjects, + validateDirectoryPath, + }); + if (!resolvedDirectory.ok) { + throw new OpenChamberControlError(resolvedDirectory.error, resolvedDirectory.status || 400); + } + + if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250); + + const directory = resolvedDirectory.directory; + const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, ''); + const client = createOpencodeClient({ baseUrl, headers: getOpenCodeAuthHeaders() }); + + const archived = []; + const failedIds = []; + for (const sessionID of parsed.ids) { + try { + const response = await client.session.update({ + sessionID, + directory, + time: { archived: parsed.archivedAt }, + }); + const session = response?.data; + if (session?.id) archived.push(session); + else failedIds.push(sessionID); + } catch (error) { + console.warn('[OpenChamberSessions] failed to archive session', sessionID, error); + failedIds.push(sessionID); + } + } + + return { directory, archived, failedIds }; + }; + const create = async (payload = {}) => { const title = asNonEmptyString(payload.title); const prompt = asNonEmptyString(payload.prompt); @@ -813,6 +905,7 @@ export const createOpenChamberSessionService = (dependencies) => { return { create, + archive, send: (sessionID, payload) => runExisting('send', sessionID, payload), fork: (sessionID, payload) => runExisting('fork', sessionID, payload), }; @@ -843,6 +936,15 @@ export const registerOpenChamberSessionRoutes = (app, dependencies) => { } }); + app.post('/api/openchamber/sessions/archive', express.json({ limit: '1mb' }), async (req, res) => { + try { + return res.json(await service.archive(req.body && typeof req.body === 'object' ? req.body : {})); + } catch (error) { + console.error('[OpenChamberSessions] failed to archive sessions:', error); + return sendServiceError(res, error, 'Failed to archive sessions'); + } + }); + app.post( '/api/openchamber/sessions/:sessionId/send', express.json({ limit: '1mb' }), diff --git a/packages/web/server/lib/openchamber-sessions/routes.test.js b/packages/web/server/lib/openchamber-sessions/routes.test.js index c95fd6e2..6a60ce73 100644 --- a/packages/web/server/lib/openchamber-sessions/routes.test.js +++ b/packages/web/server/lib/openchamber-sessions/routes.test.js @@ -17,6 +17,7 @@ const getWorktreeBootstrapStatusMock = vi.fn(async () => ({ const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } })); const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } })); const sessionMessagesMock = vi.fn(async () => ({ data: [] })); +const sessionUpdateMock = vi.fn(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } })); let existingSessionMessages = []; let dispatchedUserMessageSeq = 0; @@ -78,6 +79,7 @@ vi.mock('@opencode-ai/sdk/v2', () => ({ fork: sessionForkMock, messages: sessionMessagesMock, command: sessionCommandMock, + update: sessionUpdateMock, }, command: { list: commandListMock, @@ -132,6 +134,92 @@ describe('openchamber session routes', () => { sessionCommandMock.mockResolvedValue({ data: {} }); commandListMock.mockReset(); commandListMock.mockResolvedValue({ data: [] }); + sessionUpdateMock.mockReset(); + sessionUpdateMock.mockImplementation(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } })); + }); + + describe('archiving a batch of sessions', () => { + it('archives every id against the resolved directory and returns the archived sessions', async () => { + const { app } = createApp(); + const response = await request(app) + .post('/api/openchamber/sessions/archive') + .send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'], archivedAt: 1700 }) + .expect(200); + + expect(response.body.directory).toBe('/repo/app'); + expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_b']); + expect(response.body.failedIds).toEqual([]); + expect(sessionUpdateMock).toHaveBeenCalledTimes(2); + expect(sessionUpdateMock).toHaveBeenCalledWith({ + sessionID: 'ses_a', + directory: '/repo/app', + time: { archived: 1700 }, + }); + }); + + it('keeps archiving after a failed session and reports it as failed', async () => { + sessionUpdateMock.mockImplementation(async ({ sessionID }) => { + if (sessionID === 'ses_b') throw new Error('session.update failed'); + return { data: { id: sessionID } }; + }); + + const { app } = createApp(); + const response = await request(app) + .post('/api/openchamber/sessions/archive') + .send({ directory: '/repo/app', ids: ['ses_a', 'ses_b', 'ses_c'] }) + .expect(200); + + expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_c']); + expect(response.body.failedIds).toEqual(['ses_b']); + }); + + it('reports a session the server did not confirm as failed instead of archived', async () => { + sessionUpdateMock.mockImplementation(async ({ sessionID }) => ( + sessionID === 'ses_b' ? { data: null } : { data: { id: sessionID } } + )); + + const { app } = createApp(); + const response = await request(app) + .post('/api/openchamber/sessions/archive') + .send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'] }) + .expect(200); + + expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a']); + expect(response.body.failedIds).toEqual(['ses_b']); + }); + + it('rejects an empty batch, an oversized batch, and non-string ids', async () => { + const { app } = createApp(); + + await request(app).post('/api/openchamber/sessions/archive').send({ directory: '/repo/app', ids: [] }).expect(400); + await request(app) + .post('/api/openchamber/sessions/archive') + .send({ directory: '/repo/app', ids: Array.from({ length: 501 }, (_, index) => `ses_${index}`) }) + .expect(400); + await request(app) + .post('/api/openchamber/sessions/archive') + .send({ directory: '/repo/app', ids: ['ses_a', ''] }) + .expect(400); + await request(app) + .post('/api/openchamber/sessions/archive') + .send({ directory: '/repo/app', ids: ['ses_a'], archivedAt: -1 }) + .expect(400); + + expect(sessionUpdateMock).not.toHaveBeenCalled(); + }); + + it('rejects a directory the runtime does not accept', async () => { + const { app } = createApp({ + validateDirectoryPath: async () => ({ ok: false, error: 'Invalid directory' }), + }); + + await request(app) + .post('/api/openchamber/sessions/archive') + .send({ directory: '/elsewhere', ids: ['ses_a'] }) + .expect(400); + + expect(sessionUpdateMock).not.toHaveBeenCalled(); + }); }); it('creates a session for a directory', async () => {