From cee1daba65fddaec9d49764d22af0285dffa934c Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 25 Jul 2026 11:11:17 -0700 Subject: [PATCH] feat(threads): auto-mark worktree threads done on PR merge - Add localized setting and persist its default-enabled preference - Defer completion until active turns finish and update sidebar done grouping --- src/main/sharedSettingsFile.test.ts | 2 + src/renderer/actions/threadActions.ts | 47 ++++--- src/renderer/hooks/useAppHydration.ts | 4 + src/renderer/locales/de/messages.po | 17 ++- src/renderer/locales/en/messages.po | 17 ++- src/renderer/locales/es/messages.po | 17 ++- src/renderer/locales/fr/messages.po | 17 ++- src/renderer/locales/ja/messages.po | 17 ++- src/renderer/locales/ko/messages.po | 17 ++- src/renderer/locales/pl/messages.po | 17 ++- src/renderer/locales/pt-BR/messages.po | 17 ++- src/renderer/locales/ru/messages.po | 17 ++- src/renderer/locales/tr/messages.po | 17 ++- src/renderer/locales/uk/messages.po | 17 ++- src/renderer/locales/vi/messages.po | 17 ++- src/renderer/locales/zh-CN/messages.po | 17 ++- src/renderer/state/prMergeAutoDone.test.ts | 120 ++++++++++++++++++ src/renderer/state/prMergeAutoDone.ts | 99 +++++++++++++++ src/renderer/state/sharedSettingsStore.ts | 7 + .../Sidebar/parts/SidebarThreadGroup.tsx | 4 +- .../Sidebar/parts/SidebarWorktreeGroup.tsx | 4 +- .../parts/ThreadItemSuffix.tsx | 34 ++++- .../parts/Sidebar/parts/groupThreads.ts | 10 ++ .../Sidebar/parts/sidebarProjectRows.test.ts | 57 +++++++++ .../parts/Sidebar/parts/sidebarProjectRows.ts | 40 ++++-- .../SettingsOverlay/parts/ThreadSettings.tsx | 27 +++- .../parts/settingsSearchIndex.ts | 8 ++ src/shared/settings.ts | 7 + 28 files changed, 642 insertions(+), 49 deletions(-) create mode 100644 src/renderer/state/prMergeAutoDone.test.ts create mode 100644 src/renderer/state/prMergeAutoDone.ts diff --git a/src/main/sharedSettingsFile.test.ts b/src/main/sharedSettingsFile.test.ts index f34d3e42b..b8a85f333 100644 --- a/src/main/sharedSettingsFile.test.ts +++ b/src/main/sharedSettingsFile.test.ts @@ -96,6 +96,7 @@ describe("sharedSettingsFile", () => { remoteAccessTailscaleHttps: false, remoteAccessAdvertisedUrl: "", threadRemoveAction: "archive", + autoMarkDoneOnPrMerge: true, newThreadMode: "page", homeScopeEnabled: true, sidebarTranslucency: false, @@ -215,6 +216,7 @@ describe("sharedSettingsFile", () => { remoteAccessTailscaleHttps: false, remoteAccessAdvertisedUrl: "", threadRemoveAction: "archive", + autoMarkDoneOnPrMerge: true, newThreadMode: "page", homeScopeEnabled: true, sidebarTranslucency: false, diff --git a/src/renderer/actions/threadActions.ts b/src/renderer/actions/threadActions.ts index 6f11842f1..187646a6b 100644 --- a/src/renderer/actions/threadActions.ts +++ b/src/renderer/actions/threadActions.ts @@ -321,6 +321,36 @@ export function unloadThread(threadId: string): void { void unloadStoredThread(threadId, { closeThreadPane: true }).catch(() => undefined); } +/** + * Marks a thread done: unloads its runtime, drops the worktree's terminal tabs + * once no live thread is left there, and flips the store flag. Shared by the + * manual affordances (context menu, sidebar Done button) and the PR-merge + * automation. + */ +export function markThreadDone(threadId: string): void { + if (findExperimentByThreadId(threadId)) return; + const store = useAppStore.getState(); + const thread = store.threads.find((t) => t.id === threadId); + if (!thread || thread.done) return; + + void unloadStoredThread(threadId, { keepSidePanels: true }).catch(() => undefined); + const worktreePath = thread.worktreePath; + const isLastOpenWorktreeThread = + worktreePath !== undefined && + store.threads.every( + (t) => t.id === threadId || t.worktreePath !== worktreePath || t.done || t.archived, + ); + if (worktreePath && isLastOpenWorktreeThread) { + const termStore = useDevTerminalStore.getState(); + const removedTabIds = termStore.removeTabsForWorktree(worktreePath); + void closeThreads(removedTabIds); + if (termStore.isOpen && termStore.activeWorktreePath === worktreePath) { + termStore.closePanel(); + } + } + store.markThreadDone(threadId); +} + export function toggleMarkThreadDone(threadId: string): void { if (findExperimentByThreadId(threadId)) return; const store = useAppStore.getState(); @@ -329,22 +359,7 @@ export function toggleMarkThreadDone(threadId: string): void { if (thread.done) { store.unmarkThreadDone(threadId); } else { - void unloadStoredThread(threadId, { keepSidePanels: true }).catch(() => undefined); - const worktreePath = thread.worktreePath; - const isLastOpenWorktreeThread = - worktreePath !== undefined && - store.threads.every( - (t) => t.id === threadId || t.worktreePath !== worktreePath || t.done || t.archived, - ); - if (worktreePath && isLastOpenWorktreeThread) { - const termStore = useDevTerminalStore.getState(); - const removedTabIds = termStore.removeTabsForWorktree(worktreePath); - void closeThreads(removedTabIds); - if (termStore.isOpen && termStore.activeWorktreePath === worktreePath) { - termStore.closePanel(); - } - } - store.markThreadDone(threadId); + markThreadDone(threadId); } } diff --git a/src/renderer/hooks/useAppHydration.ts b/src/renderer/hooks/useAppHydration.ts index 251f92647..c43bb3b0e 100644 --- a/src/renderer/hooks/useAppHydration.ts +++ b/src/renderer/hooks/useAppHydration.ts @@ -10,6 +10,7 @@ import { import { recoverExperimentCandidateWorktrees } from "@/renderer/state/experimentHydration"; import { hydrateThreadRuntimeItems } from "@/renderer/state/chatRuntimePersister"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { startPrMergeAutoDone } from "@/renderer/state/prMergeAutoDone"; import { startDeferredFeaturePrewarm } from "@/renderer/deferredFeatures"; interface IdleCallbackHandle { @@ -178,9 +179,12 @@ export function useAppHydration(options: { runtimeOwner?: boolean } = {}) { } }); + const stopPrMergeAutoDone = startPrMergeAutoDone(); + return () => { isActive = false; idleHandle.cancel(); + stopPrMergeAutoDone(); }; }, [ loadT0, diff --git a/src/renderer/locales/de/messages.po b/src/renderer/locales/de/messages.po index 8b6634a37..c9c4c294f 100644 --- a/src/renderer/locales/de/messages.po +++ b/src/renderer/locales/de/messages.po @@ -3478,6 +3478,7 @@ msgid "done" msgstr "erledigt" #. Notification status: thread is done +#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts #: src/renderer/views/SettingsOverlay/parts/NotificationSettings.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Done" @@ -5379,6 +5380,11 @@ msgstr "Vom Anbieter verwaltet" msgid "Manual order" msgstr "Manuelle Reihenfolge" +#. placeholder {0}: thread.title +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Mark {0} done" +msgstr "{0} als erledigt markieren" + #: src/mobile/views/threadActionSurfaces.tsx #: src/mobile/views/threadContextMenus.tsx msgid "Mark all done" @@ -5408,6 +5414,11 @@ msgstr "Als erledigt markieren" msgid "Mark Done" msgstr "Als erledigt markieren" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Mark done when the pull request merges" +msgstr "Als erledigt markieren, wenn der Pull Request zusammengeführt wird" + #: src/renderer/components/skills/SkillsManager.tsx msgid "Marketplace" msgstr "Marketplace" @@ -6557,7 +6568,6 @@ msgid "Offline. Showing the last synced data." msgstr "Offline. Die zuletzt synchronisierten Daten werden angezeigt." #: src/mobile/views/ThreadsView.tsx -#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts msgid "Older" msgstr "Älter" @@ -11282,6 +11292,11 @@ msgstr "Worktree-Stammverzeichnis für WSL-Projekte (ein Linux-Pfad)." msgid "Worktree storage location" msgstr "Worktree-Speicherort" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Worktree threads are marked done as soon as Poracode sees their pull request merge. Threads mid-turn wait until the turn finishes." +msgstr "Worktree-Threads werden als erledigt markiert, sobald Poracode das Zusammenführen ihres Pull Requests sieht. Threads mit laufendem Durchlauf warten, bis der Durchlauf endet." + #. placeholder {0}: props.projectName #. placeholder {0}: props.worktreeBranch #: src/renderer/views/MainView/parts/Sidebar/parts/GitBadge.tsx diff --git a/src/renderer/locales/en/messages.po b/src/renderer/locales/en/messages.po index 836350383..7903387de 100644 --- a/src/renderer/locales/en/messages.po +++ b/src/renderer/locales/en/messages.po @@ -3478,6 +3478,7 @@ msgid "done" msgstr "done" #. Notification status: thread is done +#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts #: src/renderer/views/SettingsOverlay/parts/NotificationSettings.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Done" @@ -5379,6 +5380,11 @@ msgstr "Managed by provider" msgid "Manual order" msgstr "Manual order" +#. placeholder {0}: thread.title +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Mark {0} done" +msgstr "Mark {0} done" + #: src/mobile/views/threadActionSurfaces.tsx #: src/mobile/views/threadContextMenus.tsx msgid "Mark all done" @@ -5408,6 +5414,11 @@ msgstr "Mark done" msgid "Mark Done" msgstr "Mark Done" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Mark done when the pull request merges" +msgstr "Mark done when the pull request merges" + #: src/renderer/components/skills/SkillsManager.tsx msgid "Marketplace" msgstr "Marketplace" @@ -6557,7 +6568,6 @@ msgid "Offline. Showing the last synced data." msgstr "Offline. Showing the last synced data." #: src/mobile/views/ThreadsView.tsx -#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts msgid "Older" msgstr "Older" @@ -11282,6 +11292,11 @@ msgstr "Worktree root for WSL projects (a Linux path)." msgid "Worktree storage location" msgstr "Worktree storage location" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Worktree threads are marked done as soon as Poracode sees their pull request merge. Threads mid-turn wait until the turn finishes." +msgstr "Worktree threads are marked done as soon as Poracode sees their pull request merge. Threads mid-turn wait until the turn finishes." + #. placeholder {0}: props.projectName #. placeholder {0}: props.worktreeBranch #: src/renderer/views/MainView/parts/Sidebar/parts/GitBadge.tsx diff --git a/src/renderer/locales/es/messages.po b/src/renderer/locales/es/messages.po index 385036dab..b1bda76e8 100644 --- a/src/renderer/locales/es/messages.po +++ b/src/renderer/locales/es/messages.po @@ -3478,6 +3478,7 @@ msgid "done" msgstr "listo" #. Notification status: thread is done +#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts #: src/renderer/views/SettingsOverlay/parts/NotificationSettings.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Done" @@ -5379,6 +5380,11 @@ msgstr "Gestionado por el proveedor" msgid "Manual order" msgstr "Orden manual" +#. placeholder {0}: thread.title +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Mark {0} done" +msgstr "Marcar {0} como hecho" + #: src/mobile/views/threadActionSurfaces.tsx #: src/mobile/views/threadContextMenus.tsx msgid "Mark all done" @@ -5408,6 +5414,11 @@ msgstr "Marcar hecho" msgid "Mark Done" msgstr "Marcar como hecho" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Mark done when the pull request merges" +msgstr "Marcar como hecho cuando se fusione la pull request" + #: src/renderer/components/skills/SkillsManager.tsx msgid "Marketplace" msgstr "Marketplace" @@ -6557,7 +6568,6 @@ msgid "Offline. Showing the last synced data." msgstr "Sin conexión. Mostrando los últimos datos sincronizados." #: src/mobile/views/ThreadsView.tsx -#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts msgid "Older" msgstr "Más antiguos" @@ -11282,6 +11292,11 @@ msgstr "Raíz de worktrees para proyectos WSL (una ruta de Linux)." msgid "Worktree storage location" msgstr "Ubicación de almacenamiento de worktrees" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Worktree threads are marked done as soon as Poracode sees their pull request merge. Threads mid-turn wait until the turn finishes." +msgstr "Los hilos de worktree se marcan como hechos en cuanto Poracode detecta que su pull request se fusiona. Los hilos con un turno en curso esperan a que el turno termine." + #. placeholder {0}: props.projectName #. placeholder {0}: props.worktreeBranch #: src/renderer/views/MainView/parts/Sidebar/parts/GitBadge.tsx diff --git a/src/renderer/locales/fr/messages.po b/src/renderer/locales/fr/messages.po index bfc357ba9..862fa7543 100644 --- a/src/renderer/locales/fr/messages.po +++ b/src/renderer/locales/fr/messages.po @@ -3478,6 +3478,7 @@ msgid "done" msgstr "fait" #. Notification status: thread is done +#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts #: src/renderer/views/SettingsOverlay/parts/NotificationSettings.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Done" @@ -5379,6 +5380,11 @@ msgstr "Gérée par le fournisseur" msgid "Manual order" msgstr "Ordre manuel" +#. placeholder {0}: thread.title +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Mark {0} done" +msgstr "Marquer {0} comme terminé" + #: src/mobile/views/threadActionSurfaces.tsx #: src/mobile/views/threadContextMenus.tsx msgid "Mark all done" @@ -5408,6 +5414,11 @@ msgstr "Marquer comme terminé" msgid "Mark Done" msgstr "Marquer terminé" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Mark done when the pull request merges" +msgstr "Marquer comme terminé lors de la fusion de la pull request" + #: src/renderer/components/skills/SkillsManager.tsx msgid "Marketplace" msgstr "Place de marché" @@ -6556,7 +6567,6 @@ msgid "Offline. Showing the last synced data." msgstr "Hors ligne. Affichage des dernières données synchronisées." #: src/mobile/views/ThreadsView.tsx -#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts msgid "Older" msgstr "Plus ancien" @@ -11281,6 +11291,11 @@ msgstr "Racine des arbres de travail pour les projets WSL (un chemin Linux)." msgid "Worktree storage location" msgstr "Emplacement de stockage des arbres de travail" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Worktree threads are marked done as soon as Poracode sees their pull request merge. Threads mid-turn wait until the turn finishes." +msgstr "Les fils d'arbre de travail sont marqués comme terminés dès que Poracode voit leur pull request fusionnée. Les fils dont un tour est en cours attendent la fin du tour." + #. placeholder {0}: props.projectName #. placeholder {0}: props.worktreeBranch #: src/renderer/views/MainView/parts/Sidebar/parts/GitBadge.tsx diff --git a/src/renderer/locales/ja/messages.po b/src/renderer/locales/ja/messages.po index a2531818a..3d524576b 100644 --- a/src/renderer/locales/ja/messages.po +++ b/src/renderer/locales/ja/messages.po @@ -3477,6 +3477,7 @@ msgid "done" msgstr "完了しました" #. Notification status: thread is done +#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts #: src/renderer/views/SettingsOverlay/parts/NotificationSettings.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Done" @@ -5378,6 +5379,11 @@ msgstr "プロバイダーが管理" msgid "Manual order" msgstr "手動並び替え" +#. placeholder {0}: thread.title +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Mark {0} done" +msgstr "{0} を完了にする" + #: src/mobile/views/threadActionSurfaces.tsx #: src/mobile/views/threadContextMenus.tsx msgid "Mark all done" @@ -5407,6 +5413,11 @@ msgstr "完了マークを付ける" msgid "Mark Done" msgstr "完了マークを付ける" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Mark done when the pull request merges" +msgstr "プルリクエストがマージされたら完了にする" + #: src/renderer/components/skills/SkillsManager.tsx msgid "Marketplace" msgstr "マーケットプレイス" @@ -6555,7 +6566,6 @@ msgid "Offline. Showing the last synced data." msgstr "オフラインです。最後に同期したデータを表示しています。" #: src/mobile/views/ThreadsView.tsx -#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts msgid "Older" msgstr "古い" @@ -11280,6 +11290,11 @@ msgstr "WSL プロジェクトのワークツリーのルート(Linux パス msgid "Worktree storage location" msgstr "ワークツリーの保存場所" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Worktree threads are marked done as soon as Poracode sees their pull request merge. Threads mid-turn wait until the turn finishes." +msgstr "Poracode がプルリクエストのマージを検知すると、ワークツリーのスレッドは完了になります。ターン実行中のスレッドはターンが終わるまで待機します。" + #. placeholder {0}: props.projectName #. placeholder {0}: props.worktreeBranch #: src/renderer/views/MainView/parts/Sidebar/parts/GitBadge.tsx diff --git a/src/renderer/locales/ko/messages.po b/src/renderer/locales/ko/messages.po index 964859110..f4e81d276 100644 --- a/src/renderer/locales/ko/messages.po +++ b/src/renderer/locales/ko/messages.po @@ -3478,6 +3478,7 @@ msgid "done" msgstr "완료" #. Notification status: thread is done +#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts #: src/renderer/views/SettingsOverlay/parts/NotificationSettings.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Done" @@ -5379,6 +5380,11 @@ msgstr "제공자가 관리함" msgid "Manual order" msgstr "수동 정렬" +#. placeholder {0}: thread.title +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Mark {0} done" +msgstr "{0} 완료로 표시" + #: src/mobile/views/threadActionSurfaces.tsx #: src/mobile/views/threadContextMenus.tsx msgid "Mark all done" @@ -5408,6 +5414,11 @@ msgstr "완료로 표시" msgid "Mark Done" msgstr "완료로 표시" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Mark done when the pull request merges" +msgstr "풀 리퀘스트가 병합되면 완료로 표시" + #: src/renderer/components/skills/SkillsManager.tsx msgid "Marketplace" msgstr "마켓플레이스" @@ -6557,7 +6568,6 @@ msgid "Offline. Showing the last synced data." msgstr "오프라인입니다. 마지막으로 동기화된 데이터를 표시합니다." #: src/mobile/views/ThreadsView.tsx -#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts msgid "Older" msgstr "이전" @@ -11282,6 +11292,11 @@ msgstr "WSL 프로젝트의 작업 트리 루트입니다(Linux 경로)." msgid "Worktree storage location" msgstr "작업 트리 저장 위치" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Worktree threads are marked done as soon as Poracode sees their pull request merge. Threads mid-turn wait until the turn finishes." +msgstr "Poracode가 풀 리퀘스트 병합을 감지하면 작업 트리 스레드가 완료로 표시됩니다. 작업이 진행 중인 스레드는 작업이 끝날 때까지 기다립니다." + #. placeholder {0}: props.projectName #. placeholder {0}: props.worktreeBranch #: src/renderer/views/MainView/parts/Sidebar/parts/GitBadge.tsx diff --git a/src/renderer/locales/pl/messages.po b/src/renderer/locales/pl/messages.po index 09a8ba9c8..c6482e6e3 100644 --- a/src/renderer/locales/pl/messages.po +++ b/src/renderer/locales/pl/messages.po @@ -3478,6 +3478,7 @@ msgid "done" msgstr "zrobione" #. Notification status: thread is done +#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts #: src/renderer/views/SettingsOverlay/parts/NotificationSettings.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Done" @@ -5379,6 +5380,11 @@ msgstr "Zarządzana przez dostawcę" msgid "Manual order" msgstr "Kolejność ręczna" +#. placeholder {0}: thread.title +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Mark {0} done" +msgstr "Oznacz {0} jako gotowe" + #: src/mobile/views/threadActionSurfaces.tsx #: src/mobile/views/threadContextMenus.tsx msgid "Mark all done" @@ -5408,6 +5414,11 @@ msgstr "Oznacz gotowe" msgid "Mark Done" msgstr "Oznacz gotowe" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Mark done when the pull request merges" +msgstr "Oznacz jako gotowe po scaleniu pull requesta" + #: src/renderer/components/skills/SkillsManager.tsx msgid "Marketplace" msgstr "Marketplace" @@ -6557,7 +6568,6 @@ msgid "Offline. Showing the last synced data." msgstr "Offline. Pokazywane są ostatnio zsynchronizowane dane." #: src/mobile/views/ThreadsView.tsx -#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts msgid "Older" msgstr "Starszy" @@ -11282,6 +11292,11 @@ msgstr "Katalog główny drzew roboczych dla projektów WSL (ścieżka Linux)." msgid "Worktree storage location" msgstr "Lokalizacja przechowywania drzewa roboczego" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Worktree threads are marked done as soon as Poracode sees their pull request merge. Threads mid-turn wait until the turn finishes." +msgstr "Wątki drzewa roboczego są oznaczane jako gotowe, gdy Poracode zauważy scalenie ich pull requesta. Wątki w trakcie tury czekają na jej zakończenie." + #. placeholder {0}: props.projectName #. placeholder {0}: props.worktreeBranch #: src/renderer/views/MainView/parts/Sidebar/parts/GitBadge.tsx diff --git a/src/renderer/locales/pt-BR/messages.po b/src/renderer/locales/pt-BR/messages.po index 0655bd747..1806b9308 100644 --- a/src/renderer/locales/pt-BR/messages.po +++ b/src/renderer/locales/pt-BR/messages.po @@ -3478,6 +3478,7 @@ msgid "done" msgstr "feito" #. Notification status: thread is done +#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts #: src/renderer/views/SettingsOverlay/parts/NotificationSettings.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Done" @@ -5379,6 +5380,11 @@ msgstr "Gerenciada pelo provedor" msgid "Manual order" msgstr "Ordem manual" +#. placeholder {0}: thread.title +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Mark {0} done" +msgstr "Marcar {0} como concluído" + #: src/mobile/views/threadActionSurfaces.tsx #: src/mobile/views/threadContextMenus.tsx msgid "Mark all done" @@ -5408,6 +5414,11 @@ msgstr "Marcar como concluído" msgid "Mark Done" msgstr "Marcar como concluído" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Mark done when the pull request merges" +msgstr "Marcar como concluído quando a pull request for mesclada" + #: src/renderer/components/skills/SkillsManager.tsx msgid "Marketplace" msgstr "Marketplace" @@ -6557,7 +6568,6 @@ msgid "Offline. Showing the last synced data." msgstr "Offline. Mostrando os últimos dados sincronizados." #: src/mobile/views/ThreadsView.tsx -#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts msgid "Older" msgstr "Mais antigo" @@ -11282,6 +11292,11 @@ msgstr "Raiz da árvore de trabalho para projetos WSL (um caminho Linux)." msgid "Worktree storage location" msgstr "Local de armazenamento da árvore de trabalho" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Worktree threads are marked done as soon as Poracode sees their pull request merge. Threads mid-turn wait until the turn finishes." +msgstr "Os tópicos de árvore de trabalho são marcados como concluídos assim que o Poracode detecta a mesclagem da pull request. Tópicos com um turno em andamento aguardam o fim do turno." + #. placeholder {0}: props.projectName #. placeholder {0}: props.worktreeBranch #: src/renderer/views/MainView/parts/Sidebar/parts/GitBadge.tsx diff --git a/src/renderer/locales/ru/messages.po b/src/renderer/locales/ru/messages.po index aee5742b3..a3ea54aae 100644 --- a/src/renderer/locales/ru/messages.po +++ b/src/renderer/locales/ru/messages.po @@ -3478,6 +3478,7 @@ msgid "done" msgstr "готово" #. Notification status: thread is done +#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts #: src/renderer/views/SettingsOverlay/parts/NotificationSettings.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Done" @@ -5379,6 +5380,11 @@ msgstr "Управляется провайдером" msgid "Manual order" msgstr "Ручной порядок" +#. placeholder {0}: thread.title +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Mark {0} done" +msgstr "Отметить {0} выполненным" + #: src/mobile/views/threadActionSurfaces.tsx #: src/mobile/views/threadContextMenus.tsx msgid "Mark all done" @@ -5408,6 +5414,11 @@ msgstr "Отметить выполненным" msgid "Mark Done" msgstr "Отметить выполненным" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Mark done when the pull request merges" +msgstr "Отмечать выполненным при слиянии pull request" + #: src/renderer/components/skills/SkillsManager.tsx msgid "Marketplace" msgstr "Маркетплейс" @@ -6557,7 +6568,6 @@ msgid "Offline. Showing the last synced data." msgstr "Офлайн. Показаны последние синхронизированные данные." #: src/mobile/views/ThreadsView.tsx -#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts msgid "Older" msgstr "Старее" @@ -11282,6 +11292,11 @@ msgstr "Корневая папка worktree для проектов WSL (пут msgid "Worktree storage location" msgstr "Место хранения worktree" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Worktree threads are marked done as soon as Poracode sees their pull request merge. Threads mid-turn wait until the turn finishes." +msgstr "Треды worktree отмечаются выполненными, как только Poracode увидит слияние их pull request. Треды с активным ходом дождутся его завершения." + #. placeholder {0}: props.projectName #. placeholder {0}: props.worktreeBranch #: src/renderer/views/MainView/parts/Sidebar/parts/GitBadge.tsx diff --git a/src/renderer/locales/tr/messages.po b/src/renderer/locales/tr/messages.po index 92147e951..546902791 100644 --- a/src/renderer/locales/tr/messages.po +++ b/src/renderer/locales/tr/messages.po @@ -3478,6 +3478,7 @@ msgid "done" msgstr "bitti" #. Notification status: thread is done +#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts #: src/renderer/views/SettingsOverlay/parts/NotificationSettings.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Done" @@ -5379,6 +5380,11 @@ msgstr "Sağlayıcı tarafından yönetiliyor" msgid "Manual order" msgstr "Manuel sıralama" +#. placeholder {0}: thread.title +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Mark {0} done" +msgstr "{0} öğesini tamamlandı olarak işaretle" + #: src/mobile/views/threadActionSurfaces.tsx #: src/mobile/views/threadContextMenus.tsx msgid "Mark all done" @@ -5408,6 +5414,11 @@ msgstr "Tamamlandı olarak işaretle" msgid "Mark Done" msgstr "Tamamlandı Olarak İşaretle" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Mark done when the pull request merges" +msgstr "Pull request birleştirildiğinde tamamlandı olarak işaretle" + #: src/renderer/components/skills/SkillsManager.tsx msgid "Marketplace" msgstr "Marketplace" @@ -6557,7 +6568,6 @@ msgid "Offline. Showing the last synced data." msgstr "Çevrimdışı. Son senkronize edilen veriler gösteriliyor." #: src/mobile/views/ThreadsView.tsx -#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts msgid "Older" msgstr "Daha eski" @@ -11282,6 +11292,11 @@ msgstr "WSL projeleri için çalışma ağacı kök dizini (bir Linux yolu)." msgid "Worktree storage location" msgstr "Çalışma ağacı depolama konumu" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Worktree threads are marked done as soon as Poracode sees their pull request merge. Threads mid-turn wait until the turn finishes." +msgstr "Çalışma ağacı konuları, Poracode pull request'in birleştirildiğini gördüğü anda tamamlandı olarak işaretlenir. Turu süren konular turun bitmesini bekler." + #. placeholder {0}: props.projectName #. placeholder {0}: props.worktreeBranch #: src/renderer/views/MainView/parts/Sidebar/parts/GitBadge.tsx diff --git a/src/renderer/locales/uk/messages.po b/src/renderer/locales/uk/messages.po index 3c2e89c39..9054de32e 100644 --- a/src/renderer/locales/uk/messages.po +++ b/src/renderer/locales/uk/messages.po @@ -3478,6 +3478,7 @@ msgid "done" msgstr "готово" #. Notification status: thread is done +#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts #: src/renderer/views/SettingsOverlay/parts/NotificationSettings.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Done" @@ -5379,6 +5380,11 @@ msgstr "Керується провайдером" msgid "Manual order" msgstr "Ручний порядок" +#. placeholder {0}: thread.title +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Mark {0} done" +msgstr "Позначити {0} виконаним" + #: src/mobile/views/threadActionSurfaces.tsx #: src/mobile/views/threadContextMenus.tsx msgid "Mark all done" @@ -5408,6 +5414,11 @@ msgstr "Позначити виконаним" msgid "Mark Done" msgstr "Позначити виконаним" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Mark done when the pull request merges" +msgstr "Позначати виконаним після злиття pull request" + #: src/renderer/components/skills/SkillsManager.tsx msgid "Marketplace" msgstr "Маркетплейс" @@ -6557,7 +6568,6 @@ msgid "Offline. Showing the last synced data." msgstr "Офлайн. Показано останні синхронізовані дані." #: src/mobile/views/ThreadsView.tsx -#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts msgid "Older" msgstr "Старіші" @@ -11282,6 +11292,11 @@ msgstr "Коренева папка worktree для проєктів WSL (шля msgid "Worktree storage location" msgstr "Розташування сховища worktree" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Worktree threads are marked done as soon as Poracode sees their pull request merge. Threads mid-turn wait until the turn finishes." +msgstr "Треди worktree позначаються виконаними, щойно Poracode побачить злиття їхнього pull request. Треди з активним ходом чекають на його завершення." + #. placeholder {0}: props.projectName #. placeholder {0}: props.worktreeBranch #: src/renderer/views/MainView/parts/Sidebar/parts/GitBadge.tsx diff --git a/src/renderer/locales/vi/messages.po b/src/renderer/locales/vi/messages.po index ca202e349..bb3f13521 100644 --- a/src/renderer/locales/vi/messages.po +++ b/src/renderer/locales/vi/messages.po @@ -3478,6 +3478,7 @@ msgid "done" msgstr "xong" #. Notification status: thread is done +#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts #: src/renderer/views/SettingsOverlay/parts/NotificationSettings.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Done" @@ -5379,6 +5380,11 @@ msgstr "Do nhà cung cấp quản lý" msgid "Manual order" msgstr "Sắp xếp thủ công" +#. placeholder {0}: thread.title +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Mark {0} done" +msgstr "Đánh dấu {0} là xong" + #: src/mobile/views/threadActionSurfaces.tsx #: src/mobile/views/threadContextMenus.tsx msgid "Mark all done" @@ -5408,6 +5414,11 @@ msgstr "Đánh dấu là xong" msgid "Mark Done" msgstr "Đánh dấu xong" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Mark done when the pull request merges" +msgstr "Đánh dấu xong khi pull request được hợp nhất" + #: src/renderer/components/skills/SkillsManager.tsx msgid "Marketplace" msgstr "Marketplace" @@ -6557,7 +6568,6 @@ msgid "Offline. Showing the last synced data." msgstr "Ngoại tuyến. Đang hiển thị dữ liệu đã đồng bộ gần nhất." #: src/mobile/views/ThreadsView.tsx -#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts msgid "Older" msgstr "Cũ hơn" @@ -11282,6 +11292,11 @@ msgstr "Thư mục gốc của cây làm việc cho các dự án WSL (một đ msgid "Worktree storage location" msgstr "Vị trí lưu trữ cây làm việc" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Worktree threads are marked done as soon as Poracode sees their pull request merge. Threads mid-turn wait until the turn finishes." +msgstr "Các luồng worktree được đánh dấu xong ngay khi Poracode thấy pull request của chúng được hợp nhất. Luồng đang giữa lượt sẽ chờ đến khi lượt kết thúc." + #. placeholder {0}: props.projectName #. placeholder {0}: props.worktreeBranch #: src/renderer/views/MainView/parts/Sidebar/parts/GitBadge.tsx diff --git a/src/renderer/locales/zh-CN/messages.po b/src/renderer/locales/zh-CN/messages.po index 71a959e04..1206a400a 100644 --- a/src/renderer/locales/zh-CN/messages.po +++ b/src/renderer/locales/zh-CN/messages.po @@ -3478,6 +3478,7 @@ msgid "done" msgstr "完成" #. Notification status: thread is done +#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts #: src/renderer/views/SettingsOverlay/parts/NotificationSettings.tsx #: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts msgid "Done" @@ -5379,6 +5380,11 @@ msgstr "由提供商托管" msgid "Manual order" msgstr "手动排序" +#. placeholder {0}: thread.title +#: src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +msgid "Mark {0} done" +msgstr "将 {0} 标记为完成" + #: src/mobile/views/threadActionSurfaces.tsx #: src/mobile/views/threadContextMenus.tsx msgid "Mark all done" @@ -5408,6 +5414,11 @@ msgstr "标记完成" msgid "Mark Done" msgstr "标记完成" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Mark done when the pull request merges" +msgstr "拉取请求合并后标记为完成" + #: src/renderer/components/skills/SkillsManager.tsx msgid "Marketplace" msgstr "市场" @@ -6556,7 +6567,6 @@ msgid "Offline. Showing the last synced data." msgstr "离线。正在显示上次同步的数据。" #: src/mobile/views/ThreadsView.tsx -#: src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts msgid "Older" msgstr "更早" @@ -11281,6 +11291,11 @@ msgstr "WSL 项目的工作树根目录(Linux 路径)。" msgid "Worktree storage location" msgstr "工作树存储位置" +#: src/renderer/views/SettingsOverlay/parts/settingsSearchIndex.ts +#: src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +msgid "Worktree threads are marked done as soon as Poracode sees their pull request merge. Threads mid-turn wait until the turn finishes." +msgstr "一旦 Poracode 检测到拉取请求已合并,工作树线程就会标记为完成。处于对话轮次中的线程会等到该轮次结束。" + #. placeholder {0}: props.projectName #. placeholder {0}: props.worktreeBranch #: src/renderer/views/MainView/parts/Sidebar/parts/GitBadge.tsx diff --git a/src/renderer/state/prMergeAutoDone.test.ts b/src/renderer/state/prMergeAutoDone.test.ts new file mode 100644 index 000000000..5f18c61e8 --- /dev/null +++ b/src/renderer/state/prMergeAutoDone.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PrData, Thread } from "@/shared/contracts"; +import { useAppStore } from "./appStore"; +import { useGitStore } from "./gitStore"; +import { useSharedSettings } from "./sharedSettingsStore"; +import { startPrMergeAutoDone } from "./prMergeAutoDone"; + +const markThreadDoneMock = vi.fn<(threadId: string) => void>(); + +vi.mock("@/renderer/actions/threadActions", () => ({ + markThreadDone: (threadId: string) => markThreadDoneMock(threadId), +})); + +const openPr: PrData = { + number: 7, + state: "open", + title: "Land the thing", + url: "https://github.com/owner/repo/pull/7", + baseBranch: "main", + isDraft: false, + checksStatus: "SUCCESS", + updatedAt: "2026-07-20T00:00:00.000Z", +}; +const mergedPr: PrData = { ...openPr, state: "merged" }; + +const thread: Thread = { + id: "t1", + projectId: "p1", + title: "Worktree thread", + agentKind: "codex", + config: { model: "gpt-5" }, + status: "idle", + attention: "none", + canResumeWithConfig: false, + worktreePath: "/repo-wt", + worktreeBranch: "feature/wt", + archived: false, + done: false, + starred: false, + createdAt: "2026-07-20T00:00:00.000Z", + updatedAt: "2026-07-20T00:00:00.000Z", +}; + +let stop: () => void = () => {}; + +describe("prMergeAutoDone", () => { + beforeEach(() => { + markThreadDoneMock.mockReset(); + Object.defineProperty(window, "poracode", { + configurable: true, + value: { + platform: "darwin", + dbSetState: vi + .fn<(key: string, value: string) => Promise>() + .mockResolvedValue(undefined), + }, + }); + useGitStore.setState({ prData: {} }); + useAppStore.setState({ threads: [thread], view: { kind: "home" } }); + useSharedSettings.setState({ autoMarkDoneOnPrMerge: true }); + stop = startPrMergeAutoDone(); + }); + + afterEach(() => { + stop(); + }); + + it("marks a worktree thread done when its PR turns merged", () => { + useGitStore.getState().setPrData("/repo-wt", openPr); + expect(markThreadDoneMock).not.toHaveBeenCalled(); + + useGitStore.getState().setPrData("/repo-wt", mergedPr); + expect(markThreadDoneMock).toHaveBeenCalledExactlyOnceWith("t1"); + }); + + it("ignores a PR that is already merged the first time it is seen", () => { + useGitStore.getState().setPrData("/repo-wt", mergedPr); + expect(markThreadDoneMock).not.toHaveBeenCalled(); + }); + + it("leaves threads alone while the setting is off", () => { + useSharedSettings.setState({ autoMarkDoneOnPrMerge: false }); + useGitStore.getState().setPrData("/repo-wt", openPr); + useGitStore.getState().setPrData("/repo-wt", mergedPr); + expect(markThreadDoneMock).not.toHaveBeenCalled(); + }); + + it("ignores merges on other worktrees and on the project branch", () => { + useGitStore.getState().setPrData("/other-wt", openPr); + useGitStore.getState().setPrData("/other-wt", mergedPr); + useGitStore.getState().setPrData("__branch:p1", openPr); + useGitStore.getState().setPrData("__branch:p1", mergedPr); + expect(markThreadDoneMock).not.toHaveBeenCalled(); + }); + + it("skips archived and already-done threads", () => { + useAppStore.setState({ + threads: [ + { ...thread, id: "archived", archived: true }, + { ...thread, id: "done", done: true }, + ], + }); + useGitStore.getState().setPrData("/repo-wt", openPr); + useGitStore.getState().setPrData("/repo-wt", mergedPr); + expect(markThreadDoneMock).not.toHaveBeenCalled(); + }); + + it("defers a thread that is mid-turn until the turn settles", () => { + useAppStore.setState({ threads: [{ ...thread, status: "working" }] }); + useGitStore.getState().setPrData("/repo-wt", openPr); + useGitStore.getState().setPrData("/repo-wt", mergedPr); + expect(markThreadDoneMock).not.toHaveBeenCalled(); + + useAppStore.setState({ threads: [{ ...thread, status: "needs_reply" }] }); + expect(markThreadDoneMock).not.toHaveBeenCalled(); + + useAppStore.setState({ threads: [{ ...thread, status: "idle" }] }); + expect(markThreadDoneMock).toHaveBeenCalledExactlyOnceWith("t1"); + }); +}); diff --git a/src/renderer/state/prMergeAutoDone.ts b/src/renderer/state/prMergeAutoDone.ts new file mode 100644 index 000000000..34c728fd0 --- /dev/null +++ b/src/renderer/state/prMergeAutoDone.ts @@ -0,0 +1,99 @@ +import { isThreadTurnActive, type PrData, type Thread } from "@/shared/contracts"; +import { markThreadDone } from "@/renderer/actions/threadActions"; +import { useAppStore } from "./appStore"; +import { useGitStore } from "./gitStore"; +import { useSharedSettings } from "./sharedSettingsStore"; + +/** + * Marks worktree threads done when their pull request turns merged. + * + * Only observed transitions count: the previous PR snapshot must say the PR was + * not merged yet. That snapshot survives restarts through the git store's + * persisted `prData` cache, so a PR merged while Poracode was closed still + * registers on the next refresh — but once a thread is marked done the snapshot + * reads "merged" forever after, so un-marking it by hand sticks. Only a PR whose + * very first snapshot is already merged (no cache, or one older than its TTL) is + * skipped; the sidebar row keeps a one-click Done button for that case. + * + * Threads mid-turn are deferred instead of yanked away; they are marked once + * the turn settles. + */ + +/** Threads whose PR merged while a turn was still running. */ +const pendingThreadIds = new Set(); + +type PrDataMap = Record; + +/** PR keys that just went from a known non-merged state to merged. */ +function collectFreshlyMergedKeys(next: PrDataMap, prev: PrDataMap): Set { + const keys = new Set(); + for (const [key, pr] of Object.entries(next)) { + if (pr?.state !== "merged") continue; + const before = prev[key]; + if (!before || before.state === "merged") continue; + keys.add(key); + } + return keys; +} + +/** + * The single "is this thread ready to be marked done" rule: skip what is + * already settled, hold anything mid-turn for the next pass, mark the rest. + */ +function settleThread(thread: Thread | undefined): void { + if (!thread || thread.done || thread.archived) { + if (thread) pendingThreadIds.delete(thread.id); + return; + } + if (isThreadTurnActive(thread.status)) { + pendingThreadIds.add(thread.id); + return; + } + // Drop before marking: `markThreadDone` writes to the app store, which + // re-enters the thread listener below. + pendingThreadIds.delete(thread.id); + markThreadDone(thread.id); +} + +function settleWorktreeThreads(prKeys: ReadonlySet): void { + for (const thread of useAppStore.getState().threads) { + if (thread.worktreePath && prKeys.has(thread.worktreePath)) settleThread(thread); + } +} + +function flushPendingThreads(): void { + const threads = useAppStore.getState().threads; + for (const threadId of [...pendingThreadIds]) { + const thread = threads.find((t) => t.id === threadId); + if (!thread) { + pendingThreadIds.delete(threadId); + continue; + } + settleThread(thread); + } +} + +/** Starts the watcher. Runtime-owner only, so a remote session never duplicates it. */ +export function startPrMergeAutoDone(): () => void { + const unsubscribeGit = useGitStore.subscribe((state, prev) => { + if (state.prData === prev.prData) return; + if (!useSharedSettings.getState().autoMarkDoneOnPrMerge) return; + const merged = collectFreshlyMergedKeys(state.prData, prev.prData); + if (merged.size > 0) settleWorktreeThreads(merged); + }); + + const unsubscribeThreads = useAppStore.subscribe((state, prev) => { + if (pendingThreadIds.size === 0 || state.threads === prev.threads) return; + if (!useSharedSettings.getState().autoMarkDoneOnPrMerge) { + pendingThreadIds.clear(); + return; + } + flushPendingThreads(); + }); + + return () => { + unsubscribeGit(); + unsubscribeThreads(); + pendingThreadIds.clear(); + }; +} diff --git a/src/renderer/state/sharedSettingsStore.ts b/src/renderer/state/sharedSettingsStore.ts index 06690e879..823ace2f0 100644 --- a/src/renderer/state/sharedSettingsStore.ts +++ b/src/renderer/state/sharedSettingsStore.ts @@ -77,6 +77,7 @@ interface SharedSettingsState extends SharedSettings { setStartMinimized: (value: boolean) => void; setCloseToTray: (value: boolean) => void; setThreadRemoveAction: (value: ThreadRemoveAction) => void; + setAutoMarkDoneOnPrMerge: (value: boolean) => void; setNewThreadMode: (value: NewThreadMode) => void; setHomeScopeEnabled: (value: boolean) => void; setSidebarTranslucency: (value: boolean) => void; @@ -428,6 +429,11 @@ export const useSharedSettings = create()((set, get) => ({ set({ threadRemoveAction }); persistSettings(selectSharedSettings(get())); }, + setAutoMarkDoneOnPrMerge: (autoMarkDoneOnPrMerge) => { + if (get().autoMarkDoneOnPrMerge === autoMarkDoneOnPrMerge) return; + set({ autoMarkDoneOnPrMerge }); + persistSettings(selectSharedSettings(get())); + }, setNewThreadMode: (newThreadMode) => { set({ newThreadMode }); persistSettings(selectSharedSettings(get())); @@ -788,6 +794,7 @@ function selectSharedSettings(state: SharedSettingsState): SharedSettingsInput { remoteAccessTailscaleHttps: state.remoteAccessTailscaleHttps, remoteAccessAdvertisedUrl: state.remoteAccessAdvertisedUrl, threadRemoveAction: state.threadRemoveAction, + autoMarkDoneOnPrMerge: state.autoMarkDoneOnPrMerge, newThreadMode: state.newThreadMode, homeScopeEnabled: state.homeScopeEnabled, sidebarTranslucency: state.sidebarTranslucency, diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/SidebarThreadGroup.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/SidebarThreadGroup.tsx index ef0f7cde1..f11fbdea6 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/SidebarThreadGroup.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/SidebarThreadGroup.tsx @@ -16,7 +16,7 @@ import { ContextMenu, type ContextMenuEntry } from "@/renderer/components/common import { ConfirmDialog } from "@/renderer/components/common/ConfirmDialog"; import { RelativeTime } from "@/renderer/components/common/RelativeTime"; import { discardExperiment } from "@/renderer/actions/experimentActions"; -import { archiveThread, toggleMarkThreadDone } from "@/renderer/actions/threadActions"; +import { archiveThread, markThreadDone } from "@/renderer/actions/threadActions"; import { useAppStore } from "@/renderer/state/appStore"; import { useExperimentStore } from "@/renderer/state/experimentStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; @@ -161,7 +161,7 @@ export function SidebarThreadGroup(props: { } if (key === "mark-all-done") { for (const thread of entry.group.threads) { - if (!thread.done) toggleMarkThreadDone(thread.id); + markThreadDone(thread.id); } } if (key === "archive-all") { diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/SidebarWorktreeGroup.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/SidebarWorktreeGroup.tsx index e8d04a35f..99aa10c6e 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/SidebarWorktreeGroup.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/SidebarWorktreeGroup.tsx @@ -22,7 +22,7 @@ import { import { openFilesPanel, openGitReview } from "@/renderer/actions/panelActions"; import { openWorktreeTerminal, runProjectAction } from "@/renderer/actions/terminalActions"; import { deleteWorktreeGroup } from "@/renderer/actions/worktreeActions"; -import { openNewThreadInWorktree, toggleMarkThreadDone } from "@/renderer/actions/threadActions"; +import { markThreadDone, openNewThreadInWorktree } from "@/renderer/actions/threadActions"; import { readBridge } from "@/renderer/bridge"; import { useGitStore } from "@/renderer/state/gitStore"; import { useIsWorktreeCollapsed, useSidebarUiStore } from "@/renderer/state/sidebarUiStore"; @@ -133,7 +133,7 @@ export function SidebarWorktreeGroup(props: { deleteWorktreeGroup(project.id, group.worktreePath, groupThreadIds); if (key === "mark-all-done") { for (const thread of group.threads) { - if (!thread.done) toggleMarkThreadDone(thread.id); + markThreadDone(thread.id); } } if (key === "git-sync") gitSync(project.id, group.worktreePath); diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx index 44252adb1..fc044c9b3 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx @@ -1,10 +1,12 @@ -import { Archive, FolderOpen, Star, Trash2 } from "lucide-react"; +import { Archive, CircleCheck, FolderOpen, Star, Trash2 } from "lucide-react"; import { useLingui } from "@lingui/react/macro"; import type { Thread } from "@/shared/contracts"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { usePrState } from "@/renderer/state/gitSelectors"; +import { handleKeyActivate } from "@/renderer/utils/a11y"; import { GitBadge } from "@/renderer/views/MainView/parts/Sidebar/parts/GitBadge"; import { SyncBadge } from "@/renderer/views/MainView/parts/Sidebar/parts/SyncBadge"; -import { archiveThread, deleteThread } from "@/renderer/actions/threadActions"; +import { archiveThread, deleteThread, markThreadDone } from "@/renderer/actions/threadActions"; import { openFilesPanel, openGitReview } from "@/renderer/actions/panelActions"; import { openWorktreeTerminal } from "@/renderer/actions/terminalActions"; import { AnimatedTerminalIcon } from "@/renderer/components/common/AnimatedTerminalIcon"; @@ -27,6 +29,11 @@ export function ThreadItemSuffix(props: { const { thread, showWorktreeBadge, showWorktreeFilesButton, isExperimentCandidate } = props; const { t } = useLingui(); const threadRemoveAction = useSharedSettings((s) => s.threadRemoveAction); + const prState = usePrState(thread.worktreePath); + // A merged PR means the work landed, so the row offers Done inline instead of + // sending the user through the context menu. + const showDoneButton = + !isExperimentCandidate && !thread.done && !!thread.worktreePath && prState === "merged"; const isFilesActive = useIsWorktreeFilesPanelActive(thread.worktreePath); const isGitActive = useIsWorktreeGitPanelActive(thread.worktreePath); const isTerminalActive = useIsWorktreeTerminalActive(thread.worktreePath); @@ -36,6 +43,26 @@ export function ThreadItemSuffix(props: { const hiddenPanelButtonClass = "w-0 -mr-[3px] overflow-hidden p-0 opacity-0 pointer-events-none group-hover:w-[18px] group-hover:mr-0 group-hover:p-0.5 group-hover:opacity-100 group-hover:pointer-events-auto focus-visible:w-[18px] focus-visible:mr-0 focus-visible:p-0.5 focus-visible:opacity-100 focus-visible:pointer-events-auto"; + // Sits left of the git badge so the PR glyph keeps its slot next to the + // timestamp whether or not the row is hovered. + const doneButton = showDoneButton ? ( +
{ + event.stopPropagation(); + markThreadDone(thread.id); + }} + onKeyDown={(event) => + handleKeyActivate(event, () => markThreadDone(thread.id), { stopPropagation: true }) + } + > + +
+ ) : null; + return ( <> {thread.starred && } @@ -72,6 +99,7 @@ export function ThreadItemSuffix(props: { + {doneButton} )} + {/* Rows inside a worktree group hide the badge cluster — keep the button. */} + {!showWorktreeBadge || !thread.worktreePath ? doneButton : null} thread.starred); } +/** + * A list entry counts as done when it is a done standalone thread, or a group + * whose every member is done — so a group with any live thread keeps its place + * in the list instead of sinking into the Done section. + */ +export function entryIsDone(entry: ThreadListEntry): boolean { + if (entry.kind === "thread") return entry.thread.done; + return entry.group.threads.every((thread) => thread.done); +} + /** True when an ISO timestamp falls within the last 24 hours. */ export function isRecent(iso: string): boolean { return Date.now() - new Date(iso).getTime() < 24 * 60 * 60 * 1000; diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.test.ts b/src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.test.ts index 5a0aa53ba..5ca3c3754 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.test.ts +++ b/src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.test.ts @@ -125,6 +125,63 @@ describe("buildSidebarProjectRows — See more cap (date sort)", () => { } }); + it("sinks done threads into a trailing Done section ordered by last update", () => { + const rows = build( + [ + makeThread({ id: "done-old", done: true, updatedAt: "2026-07-01T00:00:00.000Z" }), + makeThread({ id: "live", updatedAt: "2026-07-10T00:00:00.000Z" }), + makeThread({ id: "done-new", done: true, updatedAt: "2026-07-20T00:00:00.000Z" }), + ], + 10, + "updated", + ); + + expect(rows.map((row) => (row.kind === "thread" ? row.thread.id : row.kind))).toEqual([ + "live", + "section-label", + "done-new", + "done-old", + ]); + const label = rows.find((row) => row.kind === "section-label"); + expect(label).toMatchObject({ key: "done-label" }); + }); + + it("keeps a worktree group in the live list until every member is done", () => { + const worktree = { worktreePath: "/repo/wt", worktreeBranch: "feature" }; + const mixed = build( + [ + makeThread({ id: "wt-done", done: true, ...worktree }), + makeThread({ id: "wt-live", ...worktree }), + ], + 10, + "updated", + ); + expect(mixed.some((row) => row.kind === "section-label")).toBe(false); + + const allDone = build( + [ + makeThread({ id: "wt-done-1", done: true, ...worktree }), + makeThread({ id: "wt-done-2", done: true, ...worktree }), + ], + 10, + "updated", + ); + expect(allDone[0]).toMatchObject({ kind: "section-label", key: "done-label" }); + expect(allDone[1]).toMatchObject({ kind: "worktree-group" }); + }); + + it("hides done threads behind See more before live ones", () => { + const threads = [ + ...Array.from({ length: 8 }, (_, i) => makeThread({ id: `live-${i}` })), + ...Array.from({ length: 5 }, (_, i) => makeThread({ id: `done-${i}`, done: true })), + ]; + const rows = build(threads, 10, "updated"); + const visibleIds = threadRows(rows).map((r) => r.thread.id); + expect(visibleIds).toHaveLength(10); + expect(seeMore(rows)).toMatchObject({ hiddenCount: 3 }); + for (let i = 0; i < 8; i++) expect(visibleIds).toContain(`live-${i}`); + }); + it("hides candidate rows when the experiment group collapse key is set", () => { const groupedThreads = [ makeThread({ id: "candidate-1", groupId: "experiment-1", groupName: "Experiment" }), diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts b/src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts index 9dc5457b0..ec49f46c2 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts +++ b/src/renderer/views/MainView/parts/Sidebar/parts/sidebarProjectRows.ts @@ -2,10 +2,10 @@ import { msg } from "@lingui/core/macro"; import type { MessageDescriptor } from "@lingui/core"; import { isThreadTurnActive, type Thread } from "@/shared/contracts"; import { + entryIsDone, entryIsStarred, entryLatestDate, groupThreads, - isRecent, type ThreadListEntry, type WorktreeThreadGroup, } from "./groupThreads"; @@ -266,20 +266,34 @@ export function buildSidebarProjectRows(input: { const entries = groupThreads( [...input.projectThreads].sort((a, b) => b[dateField].localeCompare(a[dateField])), ); - const starredEntries = entries.filter(entryIsStarred); - const unstarredEntries = entries.filter((e) => !entryIsStarred(e)); - const recentEntries = unstarredEntries.filter((e) => isRecent(entryLatestDate(e, dateField))); - const olderEntries = unstarredEntries.filter((e) => !isRecent(entryLatestDate(e, dateField))); + // One pass into the three sections: done entries sink to the bottom, newest + // activity first — independent of the sort mode, which only orders the live + // list above. Their sort key is computed once per entry rather than per + // comparison, since a group entry has to scan its threads for it. + const starredEntries: ThreadListEntry[] = []; + const activeEntries: ThreadListEntry[] = []; + const datedDoneEntries: { entry: ThreadListEntry; updatedAt: string }[] = []; + for (const entry of entries) { + if (entryIsDone(entry)) { + datedDoneEntries.push({ entry, updatedAt: entryLatestDate(entry, "updatedAt") }); + } else if (entryIsStarred(entry)) { + starredEntries.push(entry); + } else { + activeEntries.push(entry); + } + } + const doneEntries = datedDoneEntries + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .map((item) => item.entry); const { visible, hiddenCount } = selectVisible( - [...starredEntries, ...recentEntries, ...olderEntries], + [...starredEntries, ...activeEntries, ...doneEntries], input.visibleLimit, (e) => entryIsProtected(e, liveBackgroundThreadIds), ); const starredVisible = starredEntries.filter((e) => visible.has(e)); - const recentVisible = recentEntries.filter((e) => visible.has(e)); - const olderVisible = olderEntries.filter((e) => visible.has(e)); - const hasBothSections = recentVisible.length > 0 && olderVisible.length > 0; + const activeVisible = activeEntries.filter((e) => visible.has(e)); + const doneVisible = doneEntries.filter((e) => visible.has(e)); let ungroupedIndex = 0; const nextUngroupedIndex = () => ungroupedIndex++; @@ -299,11 +313,11 @@ export function buildSidebarProjectRows(input: { }; pushList(starredVisible); - pushList(recentVisible, starredVisible.length); - if (hasBothSections) { - rows.push({ kind: "section-label", key: "older-label", label: msg`Older` }); + pushList(activeVisible, starredVisible.length); + if (doneVisible.length > 0) { + rows.push({ kind: "section-label", key: "done-label", label: msg`Done` }); } - pushList(olderVisible, starredVisible.length + recentVisible.length); + pushList(doneVisible, starredVisible.length + activeVisible.length); if (hiddenCount > 0) rows.push({ kind: "see-more", key: "see-more", hiddenCount }); return rows; diff --git a/src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx b/src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx index edb7c0159..d50367cc0 100644 --- a/src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/ThreadSettings.tsx @@ -4,7 +4,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import type { ThreadRemoveAction } from "@/shared/contracts"; import { isRemoteSession } from "@/renderer/bridge"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; -import { Select } from "@/renderer/components/common"; +import { Select, ToggleSwitch } from "@/renderer/components/common"; import { SettingRow, SettingsPage } from "./SettingsForm"; import { threadRemoveActionOptions, useLocalizedOptions } from "./settingsOptions"; @@ -20,6 +20,8 @@ export function ThreadSettings() { ); const threadRemoveAction = useSharedSettings((state) => state.threadRemoveAction); const setThreadRemoveAction = useSharedSettings((state) => state.setThreadRemoveAction); + const autoMarkDoneOnPrMerge = useSharedSettings((state) => state.autoMarkDoneOnPrMerge); + const setAutoMarkDoneOnPrMerge = useSharedSettings((state) => state.setAutoMarkDoneOnPrMerge); // Idle unloading and launch-time auto-archive run on the desktop; a remote // session's copy of these values is never read, so hide the rows there. const remote = isRemoteSession(); @@ -94,6 +96,29 @@ export function ThreadSettings() { )} + {!remote && ( + + Worktree threads are marked done as soon as Poracode sees their pull request merge. + Threads mid-turn wait until the turn finishes. + + } + > + { + startTransition(() => { + setAutoMarkDoneOnPrMerge(selected); + }); + }} + /> + + )} + {!remote && (