Skip to content

Commit 11b4cc7

Browse files
committed
fix(tasks): refresh open windows when a task is archived headlessly
The renderer only updated its task store optimistically inside its own archive flow, so archives initiated from the main process (e.g. the automations runtime) left a stale sidebar until a hard reload. taskService.archiveTask now emits a task:archived event and the task manager store mirrors the archive mutation on receipt, guarded idempotent so renderer-initiated archives are not double-processed.
1 parent 0e0be86 commit 11b4cc7

4 files changed

Lines changed: 115 additions & 1 deletion

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { events } from '@main/lib/events';
3+
import { taskArchivedChannel } from '@shared/core/tasks/taskEvents';
4+
import { archiveTask as archiveTaskOp } from './operations/archiveTask';
5+
6+
// task-service pulls in the full main-process graph at import time; mock the
7+
// heavy singletons / db so the module loads in a plain node test, and stub
8+
// every task operation so we can drive the service in isolation.
9+
vi.mock('@main/lib/events', () => ({
10+
events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) },
11+
}));
12+
vi.mock('@main/lib/logger', () => ({ log: { error: vi.fn(), warn: vi.fn(), info: vi.fn() } }));
13+
vi.mock('@main/db/client', () => ({ db: {} }));
14+
vi.mock('@main/db/schema', () => ({ tasks: {}, workspaces: {} }));
15+
vi.mock('@main/core/projects/project-manager', () => ({ projectManager: {} }));
16+
vi.mock('@main/core/workspaces/workspace-bootstrap-service', () => ({
17+
workspaceBootstrapService: {},
18+
}));
19+
vi.mock('@main/core/workspaces/workspace-registry', () => ({ workspaceRegistry: {} }));
20+
vi.mock('./task-session-manager', () => ({ taskSessionManager: {} }));
21+
vi.mock('./utils/utils', () => ({ mapTaskRowToTask: vi.fn() }));
22+
vi.mock('./operations/archiveTask', () => ({ archiveTask: vi.fn(async () => undefined) }));
23+
vi.mock('./operations/createTask', () => ({ createTask: vi.fn() }));
24+
vi.mock('./operations/deleteTask', () => ({ deleteTask: vi.fn() }));
25+
vi.mock('./operations/getDeletePreflight', () => ({ getDeletePreflight: vi.fn() }));
26+
vi.mock('./operations/getTasks', () => ({ getTasks: vi.fn() }));
27+
vi.mock('./operations/renameTask', () => ({ renameTask: vi.fn() }));
28+
vi.mock('./operations/restoreTask', () => ({ restoreTask: vi.fn() }));
29+
vi.mock('./operations/setTaskPinned', () => ({ setTaskPinned: vi.fn() }));
30+
vi.mock('./operations/updateLinkedIssue', () => ({ updateLinkedIssue: vi.fn() }));
31+
vi.mock('./operations/updateTaskStatus', () => ({ updateTaskStatus: vi.fn() }));
32+
33+
const { TaskService } = await import('./task-service');
34+
35+
const mockEmit = vi.mocked(events.emit);
36+
const mockArchiveOp = vi.mocked(archiveTaskOp);
37+
38+
describe('TaskService.archiveTask', () => {
39+
beforeEach(() => {
40+
vi.clearAllMocks();
41+
});
42+
43+
it('archives via the operation and notifies open windows', async () => {
44+
const service = new TaskService();
45+
await service.archiveTask('project-1', 'task-1');
46+
47+
expect(mockArchiveOp).toHaveBeenCalledWith('project-1', 'task-1');
48+
expect(mockEmit).toHaveBeenCalledWith(taskArchivedChannel, {
49+
taskId: 'task-1',
50+
projectId: 'project-1',
51+
});
52+
});
53+
54+
it('emits the archived event only after the archive operation completes', async () => {
55+
const order: string[] = [];
56+
mockArchiveOp.mockImplementationOnce(async () => {
57+
order.push('archive-op');
58+
});
59+
mockEmit.mockImplementationOnce(() => {
60+
order.push('emit');
61+
return undefined as never;
62+
});
63+
64+
const service = new TaskService();
65+
await service.archiveTask('project-1', 'task-1');
66+
67+
expect(order).toEqual(['archive-op', 'emit']);
68+
});
69+
});

apps/emdash-desktop/src/main/core/tasks/task-service.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import { events } from '@main/lib/events';
1111
import { HookCore, type Hookable } from '@main/lib/hookable';
1212
import { log } from '@main/lib/logger';
1313
import type { LinkedIssue } from '@shared/core/linked-issue';
14-
import { taskCreatedChannel, taskProvisionedChannel } from '@shared/core/tasks/taskEvents';
14+
import {
15+
taskArchivedChannel,
16+
taskCreatedChannel,
17+
taskProvisionedChannel,
18+
} from '@shared/core/tasks/taskEvents';
1519
import type {
1620
CreateTaskError,
1721
CreateTaskParams,
@@ -182,6 +186,10 @@ export class TaskService implements Hookable<TaskLifecycleHooks> {
182186
async archiveTask(projectId: string, taskId: string): Promise<void> {
183187
await archiveTask(projectId, taskId);
184188
this._hooks.callHookBackground('task:archived', taskId, projectId);
189+
// Notify open windows — headless initiators (automations, inbound MCP)
190+
// have no renderer-side optimistic update. The renderer handler is
191+
// idempotent for GUI-initiated archives.
192+
events.emit(taskArchivedChannel, { taskId, projectId });
185193
}
186194

187195
async restoreTask(id: string): Promise<void> {

apps/emdash-desktop/src/renderer/features/tasks/stores/task-manager.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { gitWorkspaceChangedChannel } from '@shared/core/git/gitEvents';
1515
import { prSyncProgressChannel, prUpdatedChannel } from '@shared/core/pull-requests/prEvents';
1616
import {
1717
lifecycleScriptStatusChannel,
18+
taskArchivedChannel,
1819
taskCreatedChannel,
1920
taskProvisionProgressChannel,
2021
taskProvisionedChannel,
@@ -109,6 +110,7 @@ export class TaskManagerStore {
109110
private _provisionPromises = new Map<string, Promise<void>>();
110111

111112
private _unsubTaskCreated: (() => void) | null = null;
113+
private _unsubTaskArchived: (() => void) | null = null;
112114
private _unsubPrUpdated: (() => void) | null = null;
113115
private _unsubPrSyncProgress: (() => void) | null = null;
114116
private _unsubGitWorkspaceChanged: (() => void) | null = null;
@@ -144,6 +146,28 @@ export class TaskManagerStore {
144146
});
145147
});
146148

149+
this._unsubTaskArchived = events.on(
150+
taskArchivedChannel,
151+
({ taskId, projectId: evtProjectId }) => {
152+
if (evtProjectId !== this.projectId) return;
153+
const task = this.tasks.get(taskId);
154+
// Idempotent: GUI-initiated archives already updated the store
155+
// optimistically (archivedAt set) before this event arrives. Only
156+
// headless archives (automations, inbound MCP) take this path.
157+
if (!task || !isRegistered(task) || task.data.archivedAt) return;
158+
runInAction(() => {
159+
task.data.archivedAt = new Date().toISOString();
160+
});
161+
this._releaseTaskRegistries(taskId);
162+
runInAction(() => {
163+
const current = this.tasks.get(taskId);
164+
if (current && isRegistered(current)) {
165+
current.transitionToDryUnprovisioned({ ...current.data }, 'idle');
166+
}
167+
});
168+
}
169+
);
170+
147171
this._unsubStatusUpdated = events.on(
148172
taskStatusUpdatedChannel,
149173
({ taskId, projectId: evtProjectId, status }) => {
@@ -656,6 +680,8 @@ export class TaskManagerStore {
656680
dispose(): void {
657681
this._unsubTaskCreated?.();
658682
this._unsubTaskCreated = null;
683+
this._unsubTaskArchived?.();
684+
this._unsubTaskArchived = null;
659685
this._unsubPrUpdated?.();
660686
this._unsubPrUpdated = null;
661687
this._unsubPrSyncProgress?.();

apps/emdash-desktop/src/shared/core/tasks/taskEvents.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ import { defineEvent } from '@shared/lib/ipc/events';
44

55
export const taskCreatedChannel = defineEvent<{ task: Task }>('task:created');
66

7+
/**
8+
* Emitted by the main process whenever a task is archived — regardless of
9+
* initiator (renderer, automations, inbound MCP server). The renderer's
10+
* own archive flow updates its store optimistically, so its handler for
11+
* this event must be (and is) idempotent; the event exists so headless
12+
* archives reach open windows too.
13+
*/
14+
export const taskArchivedChannel = defineEvent<{ taskId: string; projectId: string }>(
15+
'task:archived'
16+
);
17+
718
export const taskStatusUpdatedChannel = defineEvent<{
819
taskId: string;
920
projectId: string;

0 commit comments

Comments
 (0)