Skip to content

Commit 8f65275

Browse files
committed
Merge commit '6541a2dd82402ac85c79811eff69329d6bf30b71'
2 parents 81d1917 + 6541a2d commit 8f65275

11 files changed

Lines changed: 707 additions & 29 deletions

File tree

desktop/garyx-desktop/src/main/gary-client.test.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,14 @@ test("listTaskForest maps parent and run-state fields", async () => {
174174
status: "in_progress",
175175
sourceBot: "test-bot",
176176
includeDone: true,
177+
scope: "active",
177178
},
178179
);
179180

180181
assert.equal(urls.length, 1);
181182
assert.equal(
182183
urls[0],
183-
"http://127.0.0.1:31337/api/tasks/forest?status=in_progress&source_bot_id=test-bot&include_done=true",
184+
"http://127.0.0.1:31337/api/tasks/forest?status=in_progress&source_bot_id=test-bot&include_done=true&scope=active",
184185
);
185186
assert.equal(page.total, 1);
186187
assert.equal(page.projectionCurrent, true);

desktop/garyx-desktop/src/main/gary-client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5203,6 +5203,9 @@ export async function listTaskForest(
52035203
if (input.includeDone !== false) {
52045204
query.set("include_done", "true");
52055205
}
5206+
if (input.scope) {
5207+
query.set("scope", input.scope);
5208+
}
52065209

52075210
const suffix = query.toString();
52085211
const payload = await requestJson<TaskForestPayload>(

desktop/garyx-desktop/src/renderer/src/app-shell/components/TaskForestConsole.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ export function TaskForestConsole({
243243
);
244244

245245
useEffect(() => {
246+
mountedRef.current = true;
246247
return () => {
247248
mountedRef.current = false;
248249
if (smoothCameraTimeoutRef.current !== null) {
@@ -350,6 +351,7 @@ export function TaskForestConsole({
350351
try {
351352
const page = await getDesktopApi().listTaskForest({
352353
includeDone: true,
354+
scope: "active",
353355
sourceBot,
354356
});
355357
if (!mountedRef.current || skipResultForRequestRef.current === requestSequence) {
@@ -904,7 +906,7 @@ export function TaskForestConsole({
904906
) : error ? (
905907
<div className="task-forest-state error">{error}</div>
906908
) : !tasks.length ? (
907-
<div className="task-forest-state">{t("No tasks yet.")}</div>
909+
<div className="task-forest-state">{t("No active tasks right now.")}</div>
908910
) : null}
909911
<div
910912
className={`task-forest-world ${birdseye ? "birdseye" : ""} ${smoothCamera ? "smooth-camera" : ""}`}

desktop/garyx-desktop/src/renderer/src/app-shell/components/TasksPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ export function TasksPanel({
269269
}: TasksPanelProps) {
270270
const { t } = useI18n();
271271
const { entries: pluginCatalog } = useChannelPluginCatalog();
272-
const [viewMode, setViewMode] = useState<TaskViewMode>('board');
272+
const [viewMode, setViewMode] = useState<TaskViewMode>('forest');
273273
const [tasks, setTasks] = useState<DesktopTaskSummary[]>([]);
274274
const [total, setTotal] = useState(0);
275275
const [loading, setLoading] = useState(false);

desktop/garyx-desktop/src/renderer/src/app-shell/components/task-forest-layout.test.mjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,36 @@ test("summarizes hidden descendants at the depth cap", () => {
101101
assert.equal(capped.descendantStatusCounts.done, 1);
102102
});
103103

104+
test("handles rootless cycles without recursing forever", () => {
105+
const layout = buildTaskForestLayout(
106+
[
107+
task({ number: 1, parentTaskNumber: 2 }),
108+
task({ number: 2, parentTaskNumber: 1 }),
109+
],
110+
{ maxDepth: 4 },
111+
);
112+
113+
assert.deepEqual(
114+
layout.nodes.map((node) => node.task.number).sort((left, right) => left - right),
115+
[1, 2],
116+
);
117+
});
118+
119+
test("lays out a large deep chain within a bounded time", () => {
120+
const tasks = Array.from({ length: 1200 }, (_, index) =>
121+
task({
122+
number: index + 1,
123+
parentTaskNumber: index === 0 ? null : index,
124+
}),
125+
);
126+
const start = Date.now();
127+
const layout = buildTaskForestLayout(tasks, { maxDepth: tasks.length });
128+
const elapsedMs = Date.now() - start;
129+
130+
assert.equal(layout.nodes.length, tasks.length);
131+
assert.ok(elapsedMs < 750, `layout took ${elapsedMs}ms`);
132+
});
133+
104134
test("selects visible nodes with viewport overscan", () => {
105135
const layout = buildTaskForestLayout(
106136
[

desktop/garyx-desktop/src/renderer/src/app-shell/components/task-forest-layout.ts

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -166,50 +166,83 @@ export function buildTaskForestLayout(
166166
children.sort(compareTasks);
167167
}
168168

169-
const subtreeCounts = new Map<number, Record<DesktopTaskStatus, number>>();
170-
const subtreeSizes = new Map<number, number>();
169+
const subtreeValues = new Map<
170+
number,
171+
{ size: number; counts: Record<DesktopTaskStatus, number> }
172+
>();
173+
const visiting = new Set<number>();
171174

172-
function collect(task: DesktopTaskForestNode, seen = new Set<number>()): {
175+
function collect(task: DesktopTaskForestNode): {
173176
size: number;
174177
counts: Record<DesktopTaskStatus, number>;
175178
} {
176-
if (seen.has(task.number)) {
179+
const memoized = subtreeValues.get(task.number);
180+
if (memoized) {
181+
return memoized;
182+
}
183+
if (visiting.has(task.number)) {
177184
return { size: 0, counts: { ...EMPTY_STATUS_COUNTS } };
178185
}
179-
seen.add(task.number);
186+
visiting.add(task.number);
180187
let size = 1;
181188
let counts = { ...EMPTY_STATUS_COUNTS };
182189
for (const child of childrenByParent.get(task.number) ?? []) {
190+
if (visiting.has(child.number)) {
191+
continue;
192+
}
183193
counts[child.status] += 1;
184-
const childValue = collect(child, new Set(seen));
194+
const childValue = collect(child);
185195
size += childValue.size;
186196
counts = mergeCounts(counts, childValue.counts);
187197
}
188-
subtreeCounts.set(task.number, counts);
189-
subtreeSizes.set(task.number, size);
190-
return { size, counts };
198+
visiting.delete(task.number);
199+
const value = { size, counts };
200+
subtreeValues.set(task.number, value);
201+
return value;
191202
}
192203

193204
for (const root of roots) {
194205
collect(root);
195206
}
207+
for (const task of tasks) {
208+
if (!subtreeValues.has(task.number)) {
209+
roots.push(task);
210+
collect(task);
211+
}
212+
}
196213

197214
roots.sort((left, right) => {
198215
const runningDelta = Number(isRunning(right)) - Number(isRunning(left));
199216
return (
200217
runningDelta ||
201-
(subtreeSizes.get(right.number) ?? 1) - (subtreeSizes.get(left.number) ?? 1) ||
218+
(subtreeValues.get(right.number)?.size ?? 1) -
219+
(subtreeValues.get(left.number)?.size ?? 1) ||
202220
updatedTime(right) - updatedTime(left) ||
203221
left.number - right.number
204222
);
205223
});
206224

207225
const nodes: TaskForestLayoutNode[] = [];
226+
const placed = new Set<number>();
208227
let nextY = 24;
209228

210-
function place(task: DesktopTaskForestNode, depth: number, yHint: number): number {
229+
function place(
230+
task: DesktopTaskForestNode,
231+
depth: number,
232+
yHint: number,
233+
path = new Set<number>(),
234+
): number {
235+
if (placed.has(task.number) || path.has(task.number)) {
236+
return yHint;
237+
}
238+
path.add(task.number);
211239
const visibleChildren =
212-
depth + 1 < maxDepth ? childrenByParent.get(task.number) ?? [] : [];
240+
depth + 1 < maxDepth
241+
? (childrenByParent.get(task.number) ?? []).filter(
242+
(child) => !path.has(child.number),
243+
)
244+
: [];
245+
const subtree = subtreeValues.get(task.number);
213246
if (visibleChildren.length === 0) {
214247
nodes.push({
215248
task,
@@ -219,16 +252,18 @@ export function buildTaskForestLayout(
219252
height: nodeHeight,
220253
depth,
221254
hiddenDescendantCount:
222-
depth + 1 >= maxDepth ? Math.max(0, (subtreeSizes.get(task.number) ?? 1) - 1) : 0,
223-
descendantStatusCounts: subtreeCounts.get(task.number) ?? { ...EMPTY_STATUS_COUNTS },
255+
depth + 1 >= maxDepth ? Math.max(0, (subtree?.size ?? 1) - 1) : 0,
256+
descendantStatusCounts: subtree?.counts ?? { ...EMPTY_STATUS_COUNTS },
224257
});
258+
placed.add(task.number);
259+
path.delete(task.number);
225260
return yHint + nodeHeight + rowGap;
226261
}
227262

228263
let childY = yHint;
229264
const childStart = childY;
230265
for (const child of visibleChildren) {
231-
childY = place(child, depth + 1, childY);
266+
childY = place(child, depth + 1, childY, path);
232267
}
233268
const childEnd = childY - rowGap;
234269
const y = Math.max(yHint, childStart + (childEnd - childStart - nodeHeight) / 2);
@@ -240,8 +275,10 @@ export function buildTaskForestLayout(
240275
height: nodeHeight,
241276
depth,
242277
hiddenDescendantCount: 0,
243-
descendantStatusCounts: subtreeCounts.get(task.number) ?? { ...EMPTY_STATUS_COUNTS },
278+
descendantStatusCounts: subtree?.counts ?? { ...EMPTY_STATUS_COUNTS },
244279
});
280+
placed.add(task.number);
281+
path.delete(task.number);
245282
return Math.max(childY, y + nodeHeight + rowGap);
246283
}
247284

desktop/garyx-desktop/src/renderer/src/i18n/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ const zhCN: Record<string, string> = {
399399
'Delete task {taskId}? The task will leave task lists, but the backing thread and transcript stay available.': '删除任务 {taskId}?该任务会从任务列表中移除,但对应线程和转录仍会保留。',
400400
'Tasks are disabled in the gateway config.': '网关配置中尚未启用任务功能。',
401401
'No tasks yet.': '还没有任务。',
402+
'No active tasks right now.': '当前没有活跃任务。',
402403
'No tasks from this thread yet.': '当前线程还没有创建任务。',
403404
'Open a thread to see its tasks.': '打开线程后查看它创建的任务。',
404405
'No {status} tasks.': '没有{status}任务。',

desktop/garyx-desktop/src/shared/contracts.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ export interface DesktopTaskForestPage {
267267
projectionCurrent: boolean;
268268
}
269269

270+
export type DesktopTaskForestScope = 'active' | 'all';
271+
270272
export interface DesktopDreamSpan {
271273
spanId: string;
272274
dreamId: string;
@@ -336,7 +338,9 @@ export interface ListTasksInput {
336338
offset?: number;
337339
}
338340

339-
export interface ListTaskForestInput extends ListTasksInput {}
341+
export interface ListTaskForestInput extends ListTasksInput {
342+
scope?: DesktopTaskForestScope;
343+
}
340344

341345
export interface GetTaskInput {
342346
taskId: string;

0 commit comments

Comments
 (0)