diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 93eeb41a..f8a3f331 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -71,7 +71,7 @@ import { copyTextToClipboard } from '@/lib/clipboard'; import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { requestSessionTreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove'; +import { buildSessionTreeMoveMessages, requestSessionTreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove'; const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors'; @@ -1064,12 +1064,10 @@ export const Header: React.FC = () => { root, descendants, sourceDirectory: sessionDirectory, - messages: { - success: t('sessions.sidebar.session.moveToWorktree.success'), - failure: t('sessions.sidebar.session.moveToWorktree.failed'), - sourceVerificationFailed: t('sessions.sidebar.session.moveToWorktree.sourceVerificationFailed'), - applyChangesFailed: t('sessions.sidebar.session.moveToWorktree.applyChangesFailed'), - }, + messages: buildSessionTreeMoveMessages(t, { + success: 'sessions.sidebar.session.moveToWorktree.success', + failure: 'sessions.sidebar.session.moveToWorktree.failed', + }), }); }, [currentSessionId, isCurrentSessionActive, isCurrentSessionMovingToWorktree, sessionDirectory, t]); diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index 1a77301e..68f2fba7 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -20,6 +20,12 @@ kept at this root in `types.ts` and `utils.tsx`. changes and roll back session-only if a later descendant fails. The root moves last and carries source changes once, which prevents rollback from replaying the transferred patch into the source. +- Failure cleanup: a worktree created for the move is removed only after a + definite failure. When the change-carrying request fails without confirming + its outcome, that worktree is KEPT (it may hold the only copy of the user's + changes), both directories are refreshed authoritatively because the session + may have moved server-side, and the toast points the user at the destination. + Existing destinations are never removed; they get the same guidance. `MainLayout` and `VSCodeLayout` call `useSessionListSync({ isVSCode })` unconditionally. The hook publishes complete directory bootstrap demand, diff --git a/packages/ui/src/components/session/sidebar/SessionWorktreeMoveConfirmDialog.test.tsx b/packages/ui/src/components/session/sidebar/SessionWorktreeMoveConfirmDialog.test.tsx index 3920ec4d..7b74104a 100644 --- a/packages/ui/src/components/session/sidebar/SessionWorktreeMoveConfirmDialog.test.tsx +++ b/packages/ui/src/components/session/sidebar/SessionWorktreeMoveConfirmDialog.test.tsx @@ -33,6 +33,7 @@ const makeMoveMessages = (): SessionTreeMoveMessages => ({ failure: 'move failed', sourceVerificationFailed: 'source verification failed', applyChangesFailed: 'apply changes failed', + changesMayBeInDestination: 'changes may be in destination', }); const makeExistingIntent = (): SessionTreeMoveIntent => ({ diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx index 79f69a20..c19be866 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx @@ -48,6 +48,7 @@ import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog import { FusionIcon } from '@/components/icons/FusionIcon'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { + buildSessionTreeMoveMessages, requestSessionTreeMove, useIsSessionWorktreeMovePending, } from '@/lib/worktrees/sessionWorktreeMove'; @@ -1047,12 +1048,10 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode descendants: collectNodeDescendantSessions(node), sourceDirectory: sessionDirectory, destination: target.metadata, - messages: { - success: t('sessions.sidebar.session.moveToWorktree.existingSuccess'), - failure: t('sessions.sidebar.session.moveToWorktree.existingFailed'), - sourceVerificationFailed: t('sessions.sidebar.session.moveToWorktree.sourceVerificationFailed'), - applyChangesFailed: t('sessions.sidebar.session.moveToWorktree.applyChangesFailed'), - }, + messages: buildSessionTreeMoveMessages(t, { + success: 'sessions.sidebar.session.moveToWorktree.existingSuccess', + failure: 'sessions.sidebar.session.moveToWorktree.existingFailed', + }), }); }} > @@ -1086,12 +1085,10 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode root: resolvedSession, descendants: collectNodeDescendantSessions(node), sourceDirectory: sessionDirectory, - messages: { - success: t('sessions.sidebar.session.moveToWorktree.success'), - failure: t('sessions.sidebar.session.moveToWorktree.failed'), - sourceVerificationFailed: t('sessions.sidebar.session.moveToWorktree.sourceVerificationFailed'), - applyChangesFailed: t('sessions.sidebar.session.moveToWorktree.applyChangesFailed'), - }, + messages: buildSessionTreeMoveMessages(t, { + success: 'sessions.sidebar.session.moveToWorktree.success', + failure: 'sessions.sidebar.session.moveToWorktree.failed', + }), }); }} className="[&>svg]:mr-1" diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index abedde7d..26725d0f 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -3015,6 +3015,7 @@ export const dict = { 'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Abbrechen', 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Die Änderungen in der Quelle konnten nicht geprüft werden. Es wurde kein Worktree und keine Sitzung geändert.', 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'Das Ziel konnte die Änderungen der Quelle nicht übernehmen. Sitzung und Änderungen wurden nicht verschoben. Versuche es erneut und wähle Nur Sitzung verschieben.', + 'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': 'Die Verbindung brach ab, bevor das Ziel den Wechsel bestätigt hat. Die Sitzung wurde möglicherweise nicht verschoben, und deine nicht committeten Änderungen liegen eventuell schon im Ziel-Worktree. Sieh dort nach, bevor du es erneut versuchst.', 'sessions.sidebar.session.export.failedLoadHistory': 'Die vollständige Sitzungshistorie konnte nicht geladen werden', 'sessions.sidebar.session.status.movingToWorktree': 'Sitzung wird in einen neuen Worktree verschoben', 'gitView.header.updateBranch': 'Branch aktualisieren', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 984f55d0..8ccc99c0 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -563,6 +563,7 @@ export const dict = { 'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Cancel', 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Source changes could not be verified. No worktree or session was changed.', 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'The destination could not accept the source changes. The session and source changes were not moved. Retry and choose Move session only.', + 'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': 'The connection dropped before the destination confirmed the move. The session may not have moved, and your uncommitted changes may already be in the destination worktree. Check there before retrying.', 'sessions.sidebar.session.menu.runFusion': 'Run fusion', 'sessions.sidebar.session.menu.openInSidePanel': 'Open in Side Panel', 'sessions.sidebar.session.actions.openInEditor': 'Open in Editor', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 9926cde4..35cdef68 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -564,6 +564,7 @@ export const dict: Record = { "sessions.sidebar.session.moveToWorktree.confirm.cancel": "Cancelar", "sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "No se pudieron verificar los cambios de la fuente. No se modificó ningún worktree ni sesión.", "sessions.sidebar.session.moveToWorktree.applyChangesFailed": "El destino no pudo aceptar los cambios de la fuente. No se movieron la sesión ni los cambios. Reintenta y elige Mover solo la sesión.", + "sessions.sidebar.session.moveToWorktree.changesMayBeInDestination": "La conexión se cortó antes de que el destino confirmara el movimiento. Puede que la sesión no se haya movido y que tus cambios sin confirmar ya estén en el worktree de destino. Compruébalo antes de volver a intentarlo.", "sessions.sidebar.session.menu.runFusion": "Ejecutar fusion", "sessions.sidebar.session.menu.openInSidePanel": "Abrir en panel lateral", "sessions.sidebar.session.actions.openInEditor": "Abrir en el editor", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index d6076d07..6a8b1fed 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -394,6 +394,7 @@ export const dict = { 'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Annuler', 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Les modifications de la source n’ont pas pu être vérifiées. Aucun worktree ni session n’a été modifié.', 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'La destination n’a pas pu accepter les modifications de la source. La session et les modifications n’ont pas été déplacées. Réessayez et choisissez Déplacer la session uniquement.', + 'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': 'La connexion a été perdue avant que la destination ne confirme le déplacement. La session n’a peut-être pas été déplacée, et vos modifications non validées se trouvent peut-être déjà dans le worktree de destination. Vérifiez-le avant de réessayer.', 'sessions.sidebar.session.menu.runFusion': 'Exécuter la fusion', 'sessions.sidebar.session.menu.openInSidePanel': 'Ouvrir dans le panneau latéral', 'sessions.sidebar.session.actions.openInEditor': 'Ouvrir dans l\'éditeur', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 39668423..1422b23b 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -564,6 +564,7 @@ export const dict: Record = { 'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'キャンセル', 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'ソースの変更を検証できませんでした。worktreeもセッションも変更されませんでした。', 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '宛先がソースの変更を受け付けられませんでした。セッションもソースの変更も移動されていません。再試行して「セッションのみ移動」を選んでください。', + 'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': '移動が宛先で確定する前に接続が切れました。セッションは移動していない可能性があり、コミットしていない変更はすでに移動先のワークツリーにあるかもしれません。再試行する前に確認してください。', 'sessions.sidebar.session.menu.runFusion': 'フュージョンを実行', 'sessions.sidebar.session.menu.openInSidePanel': 'サイドパネルで開く', 'sessions.sidebar.session.actions.openInEditor': 'エディターで開く', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 3bc835fc..1a075c41 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -564,6 +564,7 @@ export const dict: Record = { 'sessions.sidebar.session.moveToWorktree.confirm.cancel': '취소', 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '원본 변경 사항을 확인하지 못했습니다. worktree와 세션 모두 변경되지 않았습니다.', 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '목적지가 원본 변경 사항을 받아들이지 못했습니다. 세션과 원본 변경 사항이 이동되지 않았습니다. 다시 시도해 세션만 이동을 선택하세요.', + 'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': '목적지가 이동을 확인하기 전에 연결이 끊겼습니다. 세션이 이동하지 않았을 수 있고, 커밋하지 않은 변경 사항이 이미 대상 워크트리에 있을 수 있습니다. 다시 시도하기 전에 확인하세요.', 'sessions.sidebar.session.menu.runFusion': 'fusion 실행', 'sessions.sidebar.session.menu.openInSidePanel': '사이드 패널에서 열기', 'sessions.sidebar.session.actions.openInEditor': '편집기에서 열기', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 31a364ab..a4c534ce 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -357,6 +357,7 @@ export const dict: Record = { 'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Anuluj', 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Nie udało się zweryfikować zmian w źródle. Żaden worktree ani sesja nie został zmieniony.', 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'Cel nie mógł przyjąć zmian ze źródła. Sesja i zmiany w źródle nie zostały przeniesione. Spróbuj ponownie i wybierz Przenieś tylko sesję.', + 'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': 'Połączenie zostało zerwane, zanim cel potwierdził przeniesienie. Sesja mogła nie zostać przeniesiona, a niezatwierdzone zmiany mogą już być w docelowym worktree. Sprawdź go przed ponowną próbą.', 'sessions.sidebar.session.menu.runFusion': 'Uruchom fusion', 'sessions.sidebar.session.menu.openInSidePanel': 'Otwórz w panelu bocznym', 'sessions.sidebar.session.actions.openInEditor': 'Otwórz w edytorze', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index d1f4e278..0cc9ed72 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -564,6 +564,7 @@ export const dict: Record = { "sessions.sidebar.session.moveToWorktree.confirm.cancel": "Cancelar", "sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "As alterações da fonte não puderam ser verificadas. Nenhum worktree ou sessão foi alterado.", "sessions.sidebar.session.moveToWorktree.applyChangesFailed": "O destino não pôde aceitar as alterações da fonte. A sessão e as alterações da fonte não foram movidas. Tente novamente e escolha Mover apenas a sessão.", + "sessions.sidebar.session.moveToWorktree.changesMayBeInDestination": "A conexão caiu antes de o destino confirmar a movimentação. A sessão pode não ter sido movida, e suas alterações não commitadas podem já estar no worktree de destino. Confira lá antes de tentar de novo.", "sessions.sidebar.session.menu.runFusion": "Executar fusion", "sessions.sidebar.session.menu.openInSidePanel": "Abrir no painel lateral", "sessions.sidebar.session.actions.openInEditor": "Abrir no editor", diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index d656aa9f..a2579c55 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -524,6 +524,28 @@ export const dict = { 'sessions.sidebar.session.moveToWorktree.tooltip': 'Geçerli branch\'ten yeni bir worktree oluşturur, commit edilmemiş değişiklikleri aktarır ve bu session\'ı alt session\'larıyla birlikte oraya taşır.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Session boşta olduğunda kullanılabilir. Geçerli etkinliği durdur veya bitmesini bekle.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Bu session zaten yeni bir worktree\'ye taşınıyor.', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Worktree\'ye taşı', + 'sessions.sidebar.session.menu.newWorktree': 'Yeni worktree...', + 'sessions.sidebar.session.moveToWorktree.main': 'Ana worktree', + 'sessions.sidebar.session.moveToWorktree.refreshing': 'Worktree\'ler yenileniyor...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': 'Worktree\'ler yüklenemedi', + 'sessions.sidebar.session.moveToWorktree.current': 'Geçerli worktree', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session worktree\'ye taşındı', + 'sessions.sidebar.session.moveToWorktree.existingFailed': 'Session worktree\'ye taşınamadı', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Mevcut worktree\'leri ve bu session için yeni bir tane oluşturma seçeneğini gösterir.', + 'sessions.sidebar.session.moveToWorktree.confirm.title': 'Kaynakta commit edilmemiş değişiklikler var', + 'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Bu worktree\'de değişen dosya sayısı: {count}.', + 'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode bu değişiklikleri session\'a göre değil, dizine göre izler.', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Bu session\'ı ve alt session\'larını taşır, kaynaktaki dosyalara hiç dokunmaz.', + 'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Session dizinindeki değişiklikleri aktarır. Stage\'lenmemiş ve takip edilmeyen dosyalar başarıdan sonra kaynaktan çıkar.', + 'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Stage\'lenmiş değişiklikler kaynakta kalır ve hedefe kopyalanır.', + 'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'Hedef farklı bir Git tabanı kullanıyorsa aktarım başarısız olabilir.', + 'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Yalnızca session\'ı taşı', + 'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Kaynaktaki tüm değişiklikleri taşı', + 'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'İptal', + 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Kaynaktaki değişiklikler doğrulanamadı. Hiçbir worktree veya session değiştirilmedi.', + 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'Hedef, kaynaktaki değişiklikleri kabul edemedi. Session da kaynaktaki değişiklikler de taşınmadı. Tekrar deneyip Yalnızca session\'ı taşı seçeneğini kullan.', + 'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': 'Hedef taşımayı onaylamadan önce bağlantı koptu. Session taşınmamış olabilir ve commit edilmemiş değişikliklerin hedef worktree içinde olabilir. Tekrar denemeden önce oraya bak.', 'sessions.sidebar.session.menu.runFusion': 'Fusion\'ı çalıştır', 'sessions.sidebar.session.menu.openInSidePanel': 'Yan panelde aç', 'sessions.sidebar.session.actions.openInEditor': 'Editörde aç', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index f354ab57..866f91b2 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -564,6 +564,7 @@ export const dict: Record = { "sessions.sidebar.session.moveToWorktree.confirm.cancel": "Скасувати", "sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "Не вдалося перевірити зміни в джерелі. Жоден worktree чи сесію не змінено.", "sessions.sidebar.session.moveToWorktree.applyChangesFailed": "Призначення не змогло прийняти зміни з джерела. Сесію та зміни в джерелі не перенесено. Спробуйте знову й оберіть Перенести лише сесію.", + "sessions.sidebar.session.moveToWorktree.changesMayBeInDestination": "З’єднання обірвалося, перш ніж призначення підтвердило перенесення. Сесія могла не переїхати, а незакомічені зміни можуть уже бути в цільовому worktree. Перевірте його, перш ніж повторювати.", "sessions.sidebar.session.menu.runFusion": "Запустити fusion", "sessions.sidebar.session.menu.openInSidePanel": "Відкрити на бічній панелі", "sessions.sidebar.session.actions.openInEditor": "Відкрити в редакторі", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index cfface92..0200f596 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -564,6 +564,7 @@ export const dict: Record = { 'sessions.sidebar.session.moveToWorktree.confirm.cancel': '取消', 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '无法验证源的更改。未更改任何工作树或会话。', 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '目的地无法接受源的更改。会话和源更改均未移动。请重试并选择“仅移动会话”。', + 'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': '在目的地确认移动之前连接中断。会话可能没有移动,未提交的更改可能已经在目标工作树中。重试前请先检查。', 'sessions.sidebar.session.menu.runFusion': '运行融合', 'sessions.sidebar.session.menu.openInSidePanel': '在侧边面板中打开', 'sessions.sidebar.session.actions.openInEditor': '在编辑器中打开', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 03027754..8ade83fa 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -577,6 +577,7 @@ export const dict: Record = { 'sessions.sidebar.session.moveToWorktree.confirm.cancel': '取消', 'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '無法驗證來源的變更。未變更任何工作樹或工作階段。', 'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '目的地無法接受來源的變更。工作階段與來源變更均未移動。請重試並選擇「僅移動工作階段」。', + 'sessions.sidebar.session.moveToWorktree.changesMayBeInDestination': '在目的地確認移動之前連線中斷。工作階段可能沒有移動,未提交的變更可能已經在目標工作樹中。重試前請先檢查。', 'sessions.sidebar.session.menu.runFusion': '執行 fusion', 'sessions.sidebar.session.menu.openInSidePanel': '在側邊面板中開啟', 'sessions.sidebar.session.actions.openInEditor': '在編輯器中開啟', diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts index 07485e59..8ee1f599 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts @@ -7,6 +7,7 @@ import path from 'node:path'; import type { State } from '@/sync/types'; import type { WorktreeMetadata } from '@/types/worktree'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; +import { markAmbiguousTransportFailure } from '@/lib/relay/transport-error'; import type { SessionTreeMoveIntent, SessionTreeMoveMessages } from './sessionWorktreeMove'; const moveCalls: Array<{ @@ -175,11 +176,28 @@ mock.module('@/stores/useGlobalSessionsStore', () => ({ }, })); +// Mirrors session-actions: every child store is scanned, because a session's +// live status can be reported by a directory other than its own, and "no store +// covers this session" is 'unknown', never 'idle' — a populated store map says +// nothing about a session none of its stores holds. +const getSessionLiveActivity = (sessionId: string): 'unknown' | 'idle' | 'active' => { + for (const state of directoryStates.values()) { + const status = state.session_status[sessionId]; + if (status && status.type !== 'idle') return 'active'; + } + for (const state of directoryStates.values()) { + if (Object.hasOwn(state.session_status, sessionId)) return 'idle'; + } + return 'unknown'; +}; + mock.module('@/sync/session-actions', () => ({ moveSessionToDirectory: (session: Session, sourceDirectory: string, destinationDirectory: string, moveChanges = true) => { moveCalls.push({ sessionId: session.id, sourceDirectory, destinationDirectory, moveChanges }); return moveSessionImplementation(session, sourceDirectory, destinationDirectory, moveChanges); }, + getSessionLiveActivity, + isSessionBusyNow: (sessionId: string) => getSessionLiveActivity(sessionId) === 'active', })); mock.module('@/sync/session-ui-store', () => ({ @@ -239,6 +257,7 @@ const makeMoveMessages = (): SessionTreeMoveMessages => ({ failure: 'move failed', sourceVerificationFailed: 'source verification failed', applyChangesFailed: 'apply changes failed', + changesMayBeInDestination: 'changes may be in destination', }); const makeQuickIntent = (): SessionTreeMoveIntent => ({ @@ -1044,6 +1063,206 @@ describe('moveSessionTreeToExistingWorktree', () => { expect(toastErrors).toEqual([{ title: 'move failed', description: 'Destination directory belongs to another project' }]); }); + const requestDirtyQuickMove = (): void => { + getGitStatusImplementation = async () => ({ + current: 'feature', + isClean: false, + files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }], + }); + requestSessionTreeMove(makeQuickIntent()); + }; + + test('keeps a newly created worktree when an ambiguous failure may have transferred the changes', async () => { + setStatuses('/source', { root: 'idle' }); + moveSessionImplementation = async () => { + throw new Error('Request timed out'); + }; + + requestDirtyQuickMove(); + await waitFor(() => getSessionTreeMoveConfirmation() !== null); + confirmSessionTreeMove(true); + + await waitFor(() => toastErrors.length === 1); + expect(toastErrors).toEqual([{ title: 'move failed', description: 'changes may be in destination' }]); + expect(removeWorktreeCalls).toEqual([]); + }); + + test('removes a newly created worktree when the change transfer is definitely rejected', async () => { + setStatuses('/source', { root: 'idle' }); + moveSessionImplementation = async () => { + throw Object.assign(new Error('Unable to apply your changes in the destination directory: conflict'), { status: 400 }); + }; + + requestDirtyQuickMove(); + await waitFor(() => getSessionTreeMoveConfirmation() !== null); + confirmSessionTreeMove(true); + + await waitFor(() => toastErrors.length === 1); + expect(toastErrors).toEqual([{ title: 'move failed', description: 'apply changes failed' }]); + expect(removeWorktreeCalls).toEqual([{ + projectDirectory: '/repo', + directory: '/created-worktree', + deleteLocalBranch: true, + }]); + }); + + test('removes a newly created worktree when an ambiguous failure carried no changes', async () => { + setStatuses('/source', { root: 'idle' }); + moveSessionImplementation = async () => { + throw new Error('Request timed out'); + }; + + requestDirtyQuickMove(); + await waitFor(() => getSessionTreeMoveConfirmation() !== null); + confirmSessionTreeMove(false); + + await waitFor(() => toastErrors.length === 1); + expect(toastErrors).toEqual([{ title: 'move failed', description: 'Request timed out' }]); + expect(removeWorktreeCalls).toEqual([{ + projectDirectory: '/repo', + directory: '/created-worktree', + deleteLocalBranch: true, + }]); + }); + + test('removes a newly created worktree when a descendant fails ambiguously before the root moved', async () => { + setStatuses('/source', { root: 'idle', child: 'idle' }); + moveSessionImplementation = async (session) => { + if (session.id === 'child') throw new Error('Request timed out'); + }; + + getGitStatusImplementation = async () => ({ + current: 'feature', + isClean: false, + files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }], + }); + requestSessionTreeMove({ + kind: 'quick', + root: makeSession('root'), + descendants: [makeSession('child')], + sourceDirectory: '/source', + messages: makeMoveMessages(), + }); + await waitFor(() => getSessionTreeMoveConfirmation() !== null); + confirmSessionTreeMove(true); + + await waitFor(() => toastErrors.length === 1); + expect(toastErrors).toEqual([{ title: 'move failed', description: 'Request timed out' }]); + expect(removeWorktreeCalls).toEqual([{ + projectDirectory: '/repo', + directory: '/created-worktree', + deleteLocalBranch: true, + }]); + }); + + test('refuses to move a session whose live status is reported by another directory', async () => { + setStatuses('/source', { root: 'idle' }); + setStatuses('/other-directory', { root: 'busy' }); + + await expect(moveSessionTreeToExistingWorktree({ + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + moveChanges: false, + })).rejects.toThrow('Session is not idle'); + + expect(moveCalls).toEqual([]); + }); + + test('refuses to move when no child store can report session status', async () => { + directoryStates.clear(); + + await expect(moveSessionTreeToExistingWorktree({ + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + moveChanges: false, + })).rejects.toThrow('Session status is unavailable'); + + expect(moveCalls).toEqual([]); + }); + + test('keeps a newly created worktree when the relay tags the failure as dispatched', async () => { + setStatuses('/source', { root: 'idle' }); + moveSessionImplementation = async () => { + // The relay tunnel's own tag, matched by no message heuristic. + throw markAmbiguousTransportFailure(new Error('stream aborted by host')); + }; + + requestDirtyQuickMove(); + await waitFor(() => getSessionTreeMoveConfirmation() !== null); + confirmSessionTreeMove(true); + + await waitFor(() => toastErrors.length === 1); + expect(toastErrors).toEqual([{ title: 'move failed', description: 'changes may be in destination' }]); + expect(removeWorktreeCalls).toEqual([]); + expect(refreshCalls).toEqual([['/source', '/created-worktree']]); + }); + + test('reports the destination guidance for an ambiguous existing-worktree move', async () => { + setStatuses('/source', { root: 'idle' }); + getGitStatusImplementation = async () => ({ + current: 'feature', + isClean: false, + files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }], + }); + moveSessionImplementation = async () => { + throw markAmbiguousTransportFailure(new Error('stream aborted by host')); + }; + + requestSessionTreeMove({ + kind: 'existing', + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + messages: makeMoveMessages(), + }); + await waitFor(() => getSessionTreeMoveConfirmation() !== null); + confirmSessionTreeMove(true); + + await waitFor(() => toastErrors.length === 1); + expect(toastErrors).toEqual([{ title: 'move failed', description: 'changes may be in destination' }]); + expect(removeWorktreeCalls).toEqual([]); + expect(refreshCalls).toEqual([['/source', '/destination']]); + }); + + test('keeps the destination guidance when rollback is also incomplete', async () => { + setStatuses('/source', { root: 'idle', child: 'idle' }); + setStatuses('/destination', { child: 'idle' }); + getGitStatusImplementation = async () => ({ + current: 'feature', + isClean: false, + files: [{ path: 'working.ts', index: ' ', working_dir: 'M' }], + }); + moveSessionImplementation = async (session, sourceDirectory) => { + if (session.id === 'root' && sourceDirectory === '/source') { + throw markAmbiguousTransportFailure(new Error('stream aborted by host')); + } + if (session.id === 'child' && sourceDirectory === '/destination') { + throw new Error('rollback failed'); + } + }; + + requestSessionTreeMove({ + kind: 'existing', + root: makeSession('root'), + descendants: [makeSession('child')], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + messages: makeMoveMessages(), + }); + await waitFor(() => getSessionTreeMoveConfirmation() !== null); + confirmSessionTreeMove(true); + + await waitFor(() => toastErrors.length === 1); + expect(toastErrors[0]?.title).toBe('move failed'); + expect(toastErrors[0]?.description).toContain('could not be fully rolled back'); + expect(toastErrors[0]?.description).toContain('changes may be in destination'); + }); + test('surfaces a pre-destination preparation failure without attempting removal', async () => { setStatuses('/source', { root: 'idle' }); resolveProjectRefImplementation = () => null; diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts index 4f3b842e..4c743b31 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts @@ -1,13 +1,14 @@ import type { Session } from '@opencode-ai/sdk/v2'; +import type { I18nKey } from '@/lib/i18n'; import { toast } from '@/components/ui'; import { checkIsGitRepository, getGitStatus } from '@/lib/gitApi'; import { normalizePath } from '@/lib/pathNormalization'; import { createQuickWorktree, resolveProjectRef } from '@/lib/worktreeSessionCreator'; import { getLatestWorktreeMetadata, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager'; import { refreshGlobalSessionsForDirectories } from '@/stores/useGlobalSessionsStore'; -import { moveSessionToDirectory } from '@/sync/session-actions'; +import { isAmbiguousSendFailure } from '@/sync/send-failure-classification'; +import { getSessionLiveActivity, isSessionBusyNow, moveSessionToDirectory } from '@/sync/session-actions'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { getDirectoryState } from '@/sync/sync-refs'; import type { WorktreeMetadata } from '@/types/worktree'; import { waitForWorktreeGitReady } from '@/lib/worktrees/worktreeBootstrap'; import { create } from 'zustand'; @@ -17,8 +18,22 @@ export type SessionTreeMoveMessages = { failure: string; sourceVerificationFailed: string; applyChangesFailed: string; + changesMayBeInDestination: string; }; +/** Every move surface differs only in the success/failure pair, so the shared + * failure copy is resolved once here instead of at each call site. */ +export const buildSessionTreeMoveMessages = ( + t: (key: I18nKey) => string, + keys: { success: I18nKey; failure: I18nKey }, +): SessionTreeMoveMessages => ({ + success: t(keys.success), + failure: t(keys.failure), + sourceVerificationFailed: t('sessions.sidebar.session.moveToWorktree.sourceVerificationFailed'), + applyChangesFailed: t('sessions.sidebar.session.moveToWorktree.applyChangesFailed'), + changesMayBeInDestination: t('sessions.sidebar.session.moveToWorktree.changesMayBeInDestination'), +}); + export type SessionTreeMoveIntent = | { kind: 'existing'; @@ -87,6 +102,13 @@ const setSessionMoveRequesting = (sessionId: string, requesting: boolean): void }); }; +// The control plane flattens every move failure into a single +// `MoveSessionError` carrying only `data.message`, so there is no status or +// error code to match on. This prefix is the exact text OpenCode's +// `message(MoveSession.ApplyChangesError)` returns in +// `packages/opencode/src/server/routes/instance/httpapi/handlers/control-plane.ts`. +// If upstream reworks that wording the friendlier toast silently degrades to +// the raw message, which is why the fallback stays readable. const APPLY_CHANGES_MESSAGE = 'Unable to apply your changes in the destination directory'; const isApplyChangesError = (error: Error): boolean => { @@ -116,16 +138,16 @@ const resolveSourceBranch = async (directory: string, projectDirectory: string): throw new Error('Unable to determine the current branch'); }; -const assertSessionsIdle = (sessions: Session[], sourceDirectory: string): void => { - const directoryState = getDirectoryState(sourceDirectory); - if (!directoryState) throw new Error('Session status is unavailable'); - - const statuses = directoryState.session_status; - const hasActiveSession = sessions.some((session) => { - const status = statuses[session.id]?.type; - return status === 'busy' || status === 'retry'; - }); - if (hasActiveSession) throw new Error('Session is not idle'); +// Scans every child store instead of the source directory's: a session's live +// status can be reported by a directory other than the one that wins the +// directory dedup, and a directory-scoped read would then see no status at all +// and move a running session. +const assertSessionsIdle = (sessions: Session[]): void => { + for (const session of sessions) { + const activity = getSessionLiveActivity(session.id); + if (activity === 'unknown') throw new Error('Session status is unavailable'); + if (activity === 'active') throw new Error('Session is not idle'); + } }; type RollbackFailure = { @@ -133,21 +155,34 @@ type RollbackFailure = { error: Error; }; -const createIncompleteRollbackError = (moveError: Error, rollbackFailures: RollbackFailure[]): Error => { +/** Rollback left sessions in the destination. `changesMayBeInDestination` says + * the same failure also carried the working tree changes with an unknown + * outcome, so the toast must keep that guidance instead of dropping it. */ +class IncompleteRollbackError extends Error { + readonly changesMayBeInDestination: boolean; + + constructor(message: string, cause: unknown, changesMayBeInDestination: boolean) { + super(message, { cause }); + this.name = 'IncompleteRollbackError'; + this.changesMayBeInDestination = changesMayBeInDestination; + } +} + +const createIncompleteRollbackError = ( + moveError: Error, + rollbackFailures: RollbackFailure[], + changesMayBeInDestination: boolean, +): Error => { const rollbackSummary = rollbackFailures .map(({ sessionId, error }) => `${sessionId}: ${error.message}`) .join(', '); - return new Error( + return new IncompleteRollbackError( `Session move partially failed and could not be fully rolled back: ${moveError.message}. Rollback failures: ${rollbackSummary}`, - { cause: { moveError, rollbackFailures } }, + { moveError, rollbackFailures }, + changesMayBeInDestination, ); }; -const isSessionBusyOrRetrying = (session: Session, directory: string): boolean => { - const status = getDirectoryState(directory)?.session_status[session.id]?.type; - return status === 'busy' || status === 'retry'; -}; - const rollbackMovedSessions = async ( sessions: Session[], sourceDirectory: string, @@ -156,7 +191,7 @@ const rollbackMovedSessions = async ( ): Promise => { const failures: RollbackFailure[] = []; for (const session of [...sessions].reverse()) { - if (isSessionBusyOrRetrying(session, worktreeDirectory)) { + if (isSessionBusyNow(session.id)) { failures.push({ sessionId: session.id, error: new Error('Session is not idle') }); continue; } @@ -178,6 +213,16 @@ const rollbackMovedSessions = async ( return failures; }; +/** The move failed after the change-carrying request was already dispatched, so + * the user's changes may already be in the destination. A freshly created + * worktree is kept rather than deleted, because it may hold the only copy. */ +class ChangesMayBeInDestinationError extends Error { + constructor(moveError: Error) { + super(moveError.message, { cause: moveError }); + this.name = 'ChangesMayBeInDestinationError'; + } +} + const removeFailedWorktree = async ( project: ProjectRef, worktree: WorktreeMetadata, @@ -191,6 +236,17 @@ const removeFailedWorktree = async ( throw moveError; }; +const refreshMovedDirectories = async (sourceDirectory: string, destinationDirectory: string | undefined): Promise => { + const directories = destinationDirectory ? [sourceDirectory, destinationDirectory] : [sourceDirectory]; + try { + await refreshGlobalSessionsForDirectories(directories); + } catch (error) { + // Direct action updates already reconciled both stores. Keep the outcome + // unchanged if this best-effort authoritative refresh is unavailable. + console.warn('[session-worktree-move] Failed to refresh moved sessions', error); + } +}; + const moveSessionTreeTransaction = async ( input: { root: Session; @@ -217,10 +273,11 @@ const moveSessionTreeTransaction = async ( useSessionUIStore.getState().getWorktreeMetadata(session.id), ]), ); - assertSessionsIdle(sessions, input.sourceDirectory); + assertSessionsIdle(sessions); let destination: Awaited> | null = null; const moved: Session[] = []; + let changesMoveOutcomeUnknown = false; try { destination = await prepareDestination(); for (const [index, session] of sessions.entries()) { @@ -228,13 +285,18 @@ const moveSessionTreeTransaction = async ( // session to start running, so re-check the remaining source tree // immediately before each move. The root moves last so no later // descendant failure can require replaying a transferred patch. - assertSessionsIdle(sessions.slice(index), input.sourceDirectory); - await moveSessionToDirectory( - session, - input.sourceDirectory, - destination.directory, - session.id === input.root.id && input.moveChanges, - ); + assertSessionsIdle(sessions.slice(index)); + const movesChanges = session.id === input.root.id && input.moveChanges; + try { + await moveSessionToDirectory(session, input.sourceDirectory, destination.directory, movesChanges); + } catch (error) { + // A transport failure on the change-carrying request leaves the + // destination unknown: the server may have applied the patch before + // the response was lost. Definite rejections (the destination refused + // the patch) keep this false. + if (movesChanges && isAmbiguousSendFailure(error)) changesMoveOutcomeUnknown = true; + throw error; + } moved.push(session); if (session.id === input.root.id) continue; useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(destination.metadata)); @@ -247,9 +309,19 @@ const moveSessionTreeTransaction = async ( destination?.directory ?? input.sourceDirectory, previousMetadata, ); - if (rollbackFailures.length > 0) { - throw createIncompleteRollbackError(moveError, rollbackFailures); + if (changesMoveOutcomeUnknown) { + // The move request may have completed server-side, so the session's + // directory is unknown too. Reconcile both directories now instead of + // letting the sidebar contradict the toast until the next poll. + await refreshMovedDirectories(input.sourceDirectory, destination?.directory); } + if (rollbackFailures.length > 0) { + throw createIncompleteRollbackError(moveError, rollbackFailures, changesMoveOutcomeUnknown); + } + // Checked before `onMoveFailure` so the quick path's worktree removal + // never runs while the user's changes may be sitting in it. Both intent + // kinds share the messaging. + if (changesMoveOutcomeUnknown) throw new ChangesMayBeInDestinationError(moveError); if (destination?.onMoveFailure) { return destination.onMoveFailure(moveError); } @@ -257,13 +329,7 @@ const moveSessionTreeTransaction = async ( } useSessionUIStore.getState().setWorktreeMetadata(input.root.id, getLatestWorktreeMetadata(destination.metadata)); - try { - await refreshGlobalSessionsForDirectories([input.sourceDirectory, destination.directory]); - } catch (error) { - // Direct action updates already reconciled both stores. Keep the move - // successful if this best-effort authoritative refresh is unavailable. - console.warn('[session-worktree-move] Failed to refresh moved sessions', error); - } + await refreshMovedDirectories(input.sourceDirectory, destination.directory); return destination.directory; } finally { setSessionMovePending(input.root.id, false); @@ -315,11 +381,27 @@ const moveSessionTreeToQuickWorktree = async (input: { return { directory: worktree.path, metadata: worktree, - onMoveFailure: (error) => removeFailedWorktree(project, worktree, error), + // removeFailedWorktree force-deletes the worktree and its branch. The + // transaction skips this callback when the change transfer's outcome is + // unknown, so the worktree survives whenever it may hold the only copy. + onMoveFailure: async (error) => removeFailedWorktree(project, worktree, error), }; }); }; +const describeMoveFailure = ( + messages: SessionTreeMoveMessages, + failure: Error, + moveChanges: boolean, +): string => { + if (failure instanceof ChangesMayBeInDestinationError) return messages.changesMayBeInDestination; + if (failure instanceof IncompleteRollbackError && failure.changesMayBeInDestination) { + return `${failure.message} ${messages.changesMayBeInDestination}`; + } + if (moveChanges && isApplyChangesError(failure)) return messages.applyChangesFailed; + return failure.message; +}; + const executeSessionTreeMove = (intent: SessionTreeMoveIntent, moveChanges: boolean): void => { const movePromise = intent.kind === 'existing' ? moveSessionTreeToExistingWorktree({ @@ -341,9 +423,7 @@ const executeSessionTreeMove = (intent: SessionTreeMoveIntent, moveChanges: bool .catch((error) => { const failure = error instanceof Error ? error : new Error(String(error)); toast.error(intent.messages.failure, { - description: moveChanges && isApplyChangesError(failure) - ? intent.messages.applyChangesFailed - : failure.message, + description: describeMoveFailure(intent.messages, failure, moveChanges), }); }); }; diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 88558e7d..ba70199a 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -268,7 +268,8 @@ Rules: 6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session. 7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation. 8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message. -9. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo. +9. `SessionLiveActivity` has three answers and `unknown` is never `idle`. `getSessionLiveActivity` reports `active` when any child store or the global session-status index holds a non-idle status, `idle` only when a child store actually covers the session's directory, and `unknown` otherwise — child stores are evicted for background directories, and the global index keeps only non-idle entries, so absence of a status is not proof of idleness. Callers that gate a destructive action (worktree moves) must refuse on `unknown`. +10. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo. Examples of global-store updates performed in `session-actions.ts`: diff --git a/packages/ui/src/sync/send-failure-classification.ts b/packages/ui/src/sync/send-failure-classification.ts new file mode 100644 index 00000000..5ee8f06c --- /dev/null +++ b/packages/ui/src/sync/send-failure-classification.ts @@ -0,0 +1,49 @@ +/** + * Send-failure classification. + * + * Pure predicates over an unknown error value: no store, SDK, or transport + * imports. They live outside `session-actions` so callers (and their tests) can + * use the real classifier instead of re-implementing a partial mirror of it. + */ + +import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error" + +export function getErrorStatus(error: unknown): number | null { + if (!error || typeof error !== "object") return null + // SAFETY: `error` is a non-null object here; both probes read optional + // properties an SDK/fetch rejection may carry and validate them below. + const direct = (error as { status?: unknown }).status + if (typeof direct === "number") return direct + // SAFETY: same non-null object, optional property probe validated below. + const response = (error as { response?: { status?: unknown } }).response + return typeof response?.status === "number" ? response.status : null +} + +export function isAmbiguousSendFailure(error: unknown): boolean { + // Authoritative first: the transport that lost the request says whether it + // had already been dispatched. The text matching below only covers direct + // fetch/HTTP failures, whose wording we do not control either — relay tunnel + // aborts ("stream aborted by host", "relay keepalive timeout", …) match none + // of those patterns and used to be misread as definite failures. + if (isAmbiguousTransportFailure(error)) return true + + const status = getErrorStatus(error) + if (status === 503 || status === 504 || status === 408) return true + if (error instanceof TypeError) return true + if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) return true + + const message = error instanceof Error + ? error.message.toLowerCase() + : typeof error === "string" + ? error.toLowerCase() + : "" + + return message.includes("timeout") + || message.includes("timed out") + || message.includes("failed to fetch") + || message.includes("networkerror") + || message.includes("network error") + || message.includes("gateway timeout") + || message.includes("econnreset") + || message.includes("socket hang up") +} diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 03b277e0..b1e5aa2f 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -13,6 +13,7 @@ import { opencodeClient } from "@/lib/opencode/client" import { mergeSessionDirectoryMetadata, resolveGlobalSessionDirectory, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" import { useConfigStore } from "@/stores/useConfigStore" import { registerSessionDirectory } from "./sync-refs" +import { useGlobalSessionStatusStore } from "./global-session-status" import { recordSendFailure } from "./send-failure-log" import { isSyntheticPart } from "@/lib/messages/synthetic" import { materializeSessionSnapshots } from "./materialization" @@ -31,7 +32,8 @@ import { withLinkedIssue, type LinkedIssue } from "@/lib/linkedIssues" import { getImperativeSessionMessageLoader } from "./session-message-loader" import { cleanupPersistedSessionState } from "./session-deletion-cleanup" import { getRuntimeKey } from "@/lib/runtime-switch" -import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error" +import { markAmbiguousTransportFailure } from "@/lib/relay/transport-error" +import { getErrorStatus, isAmbiguousSendFailure } from "./send-failure-classification" import { getStaleRunningToolMessageID } from "./materialization" import { normalizePath } from "@/lib/pathNormalization" import { mergeMessages } from "./optimistic" @@ -135,7 +137,11 @@ function assertSdkSuccess(result: SdkResult, operation: string): T | undef const status = result.response?.status const error = new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`) as Error & { status?: number } if (status !== undefined) error.status = status - throw error + // Wrapping loses the original error's identity: the transport's + // "dispatched, outcome unknown" tag, a DOMException abort, a TypeError from + // fetch. Re-tag the wrapper so `isAmbiguousSendFailure` still classifies it + // as ambiguous instead of reading it as a definite server rejection. + throw isAmbiguousSendFailure(result.error) ? markAmbiguousTransportFailure(error) : error } function assertSdkData(result: SdkResult, operation: string): T { @@ -374,43 +380,6 @@ function connectionLostError(): Error { return new Error(`Connection lost${suffix}. Please wait for reconnection.`) } -function getErrorStatus(error: unknown): number | null { - if (!error || typeof error !== "object") return null - const direct = (error as { status?: unknown }).status - if (typeof direct === "number") return direct - const response = (error as { response?: { status?: unknown } }).response - return typeof response?.status === "number" ? response.status : null -} - -function isAmbiguousSendFailure(error: unknown): boolean { - // Authoritative first: the transport that lost the request says whether it - // had already been dispatched. The text matching below only covers direct - // fetch/HTTP failures, whose wording we do not control either — relay tunnel - // aborts ("stream aborted by host", "relay keepalive timeout", …) match none - // of those patterns and used to be misread as definite failures. - if (isAmbiguousTransportFailure(error)) return true - - const status = getErrorStatus(error) - if (status === 503 || status === 504 || status === 408) return true - if (error instanceof TypeError) return true - if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) return true - - const message = error instanceof Error - ? error.message.toLowerCase() - : typeof error === "string" - ? error.toLowerCase() - : "" - - return message.includes("timeout") - || message.includes("timed out") - || message.includes("failed to fetch") - || message.includes("networkerror") - || message.includes("network error") - || message.includes("gateway timeout") - || message.includes("econnreset") - || message.includes("socket hang up") -} - // Wait briefly for the pipeline to re-establish connection before failing a // send. Transient reconnects (heartbeat race, WS→SSE fallback, brief network // blip) otherwise surface as a hard "Connection lost" toast even though the @@ -443,21 +412,54 @@ type DescendantSession = { directory: string } +/** "unknown" means no live source covers this session right now, so no caller + * may treat it as idle on this answer. "idle" requires positive coverage. */ +export type SessionLiveActivity = "unknown" | "idle" | "active" + /** * A session's live status can live in a different child store than the one that * wins the directory dedup, so any store reporting a non-idle status counts. * Read at the moment of use: a descendant can start working after the subtree * snapshot was taken. + * + * Absence of a non-idle status is not proof of idleness. Child stores are + * evicted for background directories, and the global status index keeps only + * non-idle entries, so "no report" and "idle" are different answers: report + * "idle" only when a child store actually covers the session's directory. */ -function isSessionBusyNow(sessionId: string): boolean { +export function getSessionLiveActivity(sessionId: string): SessionLiveActivity { const stores = _childStores - if (!stores) return false - for (const [, store] of stores.children) { - const status = store.getState().session_status?.[sessionId] - if (status && status.type !== "idle") return true + if (stores) { + for (const [, store] of stores.children) { + const status = store.getState().session_status?.[sessionId] + if (status && status.type !== "idle") return "active" + } } - return false + + // Cross-directory live index: populated by global events and authoritative + // per-directory status snapshots, and it survives child-store eviction. + if (useGlobalSessionStatusStore.getState().statusById.has(sessionId)) return "active" + + if (!stores) return "unknown" + return isSessionCoveredByChildStore(sessionId, stores) ? "idle" : "unknown" +} + +function isSessionCoveredByChildStore(sessionId: string, stores: ChildStoreManager): boolean { + if (findSessionDirectoryInChildStores(sessionId)) return true + const directory = useSessionUIStore.getState().getDirectoryForSession(sessionId) + ?? resolveKnownSessionDirectory(sessionId) + if (!directory) return false + return stores.children.has(normalizePath(directory) ?? directory) +} + +function resolveKnownSessionDirectory(sessionId: string): string | null { + const globalSession = getGlobalSessionSnapshot(sessionId) + return globalSession ? resolveGlobalSessionDirectory(globalSession) : null +} + +export function isSessionBusyNow(sessionId: string): boolean { + return getSessionLiveActivity(sessionId) === "active" } async function abortDescendantIfBusy(sessionId: string, directory: string): Promise {