diff --git a/apps/emdash-desktop/src/main/core/settings/schema.ts b/apps/emdash-desktop/src/main/core/settings/schema.ts index 248834fc6..40d1a6557 100644 --- a/apps/emdash-desktop/src/main/core/settings/schema.ts +++ b/apps/emdash-desktop/src/main/core/settings/schema.ts @@ -37,6 +37,7 @@ export const taskSettingsSchema = z.object({ createBranchAndWorktree: z.boolean(), preserveNameCapitalization: z.boolean(), includeIssueContextByDefault: z.boolean(), + archiveOnMerge: z.boolean(), }); export const agentAutoApproveDefaultsSchema = z diff --git a/apps/emdash-desktop/src/main/core/settings/settings-registry.ts b/apps/emdash-desktop/src/main/core/settings/settings-registry.ts index 20464a378..afd1b6f82 100644 --- a/apps/emdash-desktop/src/main/core/settings/settings-registry.ts +++ b/apps/emdash-desktop/src/main/core/settings/settings-registry.ts @@ -29,6 +29,7 @@ export const SETTINGS_DEFAULTS = { createBranchAndWorktree: true, preserveNameCapitalization: false, includeIssueContextByDefault: true, + archiveOnMerge: false, }, agentAutoApproveDefaults: {}, notifications: { diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx index 78d135307..40f504f8b 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx @@ -16,6 +16,7 @@ import ResourceMonitorSettingsCard from './ResourceMonitorSettingsCard'; import SidebarMetadataSettingsCard from './SidebarMetadataSettingsCard'; import { SshConnectionsSettingsCard } from './SshConnectionsSettingsCard'; import { + ArchiveOnMergeRow, AutoGenerateTaskNamesRow, AutoTrustWorktreesRow, CreateBranchAndWorktreeRow, @@ -96,6 +97,9 @@ export function SettingsPage({ { component: , }, + { + component: , + }, { component: , }, diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/TaskSettingsRows.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/TaskSettingsRows.tsx index 0b13391ed..224673011 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/TaskSettingsRows.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/TaskSettingsRows.tsx @@ -166,6 +166,32 @@ export const IncludeIssueContextByDefaultRow: React.FC = () => { ); }; +export const ArchiveOnMergeRow: React.FC = () => { + const taskSettings = useTaskSettings(); + + return ( + + + + + } + /> + ); +}; + export const EnableTmuxRow: React.FC = () => { const { value: projects, diff --git a/apps/emdash-desktop/src/renderer/features/tasks/hooks/useTaskSettings.ts b/apps/emdash-desktop/src/renderer/features/tasks/hooks/useTaskSettings.ts index b346f2473..9029164ba 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/hooks/useTaskSettings.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/hooks/useTaskSettings.ts @@ -6,6 +6,7 @@ export interface TaskSettingsModel { createBranchAndWorktree: boolean; preserveNameCapitalization: boolean; includeIssueContextByDefault: boolean; + archiveOnMerge: boolean; loading: boolean; saving: boolean; isFieldOverridden: ( @@ -15,17 +16,20 @@ export interface TaskSettingsModel { | 'createBranchAndWorktree' | 'preserveNameCapitalization' | 'includeIssueContextByDefault' + | 'archiveOnMerge' ) => boolean; updateAutoGenerateName: (next: boolean) => void; updateAutoTrustWorktrees: (next: boolean) => void; updateCreateBranchAndWorktree: (next: boolean) => void; updatePreserveNameCapitalization: (next: boolean) => void; updateIncludeIssueContextByDefault: (next: boolean) => void; + updateArchiveOnMerge: (next: boolean) => void; resetAutoGenerateName: () => void; resetAutoTrustWorktrees: () => void; resetCreateBranchAndWorktree: () => void; resetPreserveNameCapitalization: () => void; resetIncludeIssueContextByDefault: () => void; + resetArchiveOnMerge: () => void; } export function useTaskSettings(): TaskSettingsModel { @@ -44,6 +48,7 @@ export function useTaskSettings(): TaskSettingsModel { createBranchAndWorktree: tasks?.createBranchAndWorktree ?? true, preserveNameCapitalization: tasks?.preserveNameCapitalization ?? false, includeIssueContextByDefault: tasks?.includeIssueContextByDefault ?? true, + archiveOnMerge: tasks?.archiveOnMerge ?? false, loading, saving, isFieldOverridden, @@ -52,10 +57,12 @@ export function useTaskSettings(): TaskSettingsModel { updateCreateBranchAndWorktree: (next) => update({ createBranchAndWorktree: next }), updatePreserveNameCapitalization: (next) => update({ preserveNameCapitalization: next }), updateIncludeIssueContextByDefault: (next) => update({ includeIssueContextByDefault: next }), + updateArchiveOnMerge: (next) => update({ archiveOnMerge: next }), resetAutoGenerateName: () => resetField('autoGenerateName'), resetAutoTrustWorktrees: () => resetField('autoTrustWorktrees'), resetCreateBranchAndWorktree: () => resetField('createBranchAndWorktree'), resetPreserveNameCapitalization: () => resetField('preserveNameCapitalization'), resetIncludeIssueContextByDefault: () => resetField('includeIssueContextByDefault'), + resetArchiveOnMerge: () => resetField('archiveOnMerge'), }; } diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-manager.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-manager.ts index 3ff144aab..41569bc4d 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-manager.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-manager.ts @@ -5,12 +5,15 @@ import type { ProjectSettingsStore } from '@renderer/features/projects/stores/pr import type { RepositoryStore } from '@renderer/features/projects/stores/repository-store'; import { getTaskGitStore } from '@renderer/features/tasks/stores/task-selectors'; import { events, rpc } from '@renderer/lib/ipc'; +import { appState } from '@renderer/lib/stores/app-state'; import { viewStateCache } from '@renderer/lib/stores/view-state-cache'; import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; +import type { AppSettings } from '@shared/core/app-settings'; import type { Conversation } from '@shared/core/conversations/conversations'; import type { FetchError } from '@shared/core/git/git'; import { gitWorkspaceChangedChannel } from '@shared/core/git/gitEvents'; import { prSyncProgressChannel, prUpdatedChannel } from '@shared/core/pull-requests/prEvents'; +import type { PullRequest } from '@shared/core/pull-requests/pull-requests'; import { lifecycleScriptStatusChannel, taskCreatedChannel, @@ -228,6 +231,9 @@ export class TaskManagerStore { task.prs.push(pr); } }); + void this._archiveTaskIfMerged(task, [pr]).catch((error: unknown) => { + console.error('Failed to auto-archive merged task', error); + }); } } }); @@ -269,11 +275,49 @@ export class TaskManagerStore { const result = await rpc.pullRequests.getPullRequestsForTask(this.projectId, store.data.id); if (!result.success) return; const prs = result.data.prs; + const previousPrStatuses = new Map( + isRegistered(store) ? (store.data as Task).prs.map((pr) => [pr.url, pr.status]) : [] + ); runInAction(() => { if (isRegistered(store)) { (store.data as Task).prs = prs; } }); + const newlyMergedPrs = prs.filter( + (pr) => + pr.status === 'merged' && + previousPrStatuses.get(pr.url) !== undefined && + previousPrStatuses.get(pr.url) !== 'merged' + ); + if (isRegistered(store)) { + await this._archiveTaskIfMerged(store.data as Task, newlyMergedPrs).catch( + (error: unknown) => { + console.error('Failed to auto-archive merged task', error); + } + ); + } + } + + private async _archiveTaskIfMerged(task: Task, prs: PullRequest[]): Promise { + if (task.archivedAt || !prs.some((pr) => pr.status === 'merged')) return; + const settings = (await rpc.appSettings.get('tasks')) as AppSettings['tasks']; + if (task.archivedAt || !settings.archiveOnMerge) return; + await this.archiveTask(task.id); + this._leaveTaskViewAfterArchive(task.id); + } + + private _leaveTaskViewAfterArchive(taskId: string): void { + const navigation = appState.navigation; + const params = navigation.viewParamsStore.task as + | { projectId?: string; taskId?: string } + | undefined; + if ( + navigation.currentViewId === 'task' && + params?.projectId === this.projectId && + params.taskId === taskId + ) { + navigation.navigate('project', { projectId: this.projectId }); + } } loadTasks(): Promise { diff --git a/apps/emdash-desktop/src/renderer/features/tasks/view.tsx b/apps/emdash-desktop/src/renderer/features/tasks/view.tsx index e51307a5b..75f220e1c 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/view.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/view.tsx @@ -76,7 +76,11 @@ export const taskView = { return { ok: false, redirect: 'home' }; } const taskManager = getTaskManagerStore(projectId); - if (taskManager && !taskManager.tasks.has(taskId)) { + const taskStore = taskManager?.tasks.get(taskId); + if (taskManager && !taskStore) { + return { ok: false, redirect: 'project', params: { projectId } }; + } + if (taskStore && 'archivedAt' in taskStore.data && taskStore.data.archivedAt) { return { ok: false, redirect: 'project', params: { projectId } }; } return { ok: true };