Skip to content

Commit 7a39a10

Browse files
committed
Show source conversation task subtree
1 parent 33b1654 commit 7a39a10

6 files changed

Lines changed: 780 additions & 110 deletions

File tree

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

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import { ListTree } from "lucide-react";
33

4-
import type { DesktopTaskForestTaskNode } from "@shared/contracts";
4+
import type {
5+
DesktopTaskForestNode,
6+
DesktopTaskForestTaskNode,
7+
} from "@shared/contracts";
58

69
import { getDesktopApi } from "../../platform/desktop-api";
710
import { useI18n } from "../../i18n";
@@ -16,7 +19,6 @@ import {
1619
taskStatusLabel,
1720
taskStatusTone,
1821
taskTreeBadgeCount,
19-
visibleTaskTreeTasks,
2022
} from "./thread-task-tree-popover-model";
2123

2224
const REFRESH_MS = 5000;
@@ -37,7 +39,7 @@ export function ThreadTaskTreePopover({
3739
onOpenThread,
3840
}: ThreadTaskTreePopoverProps) {
3941
const { t } = useI18n();
40-
const [tasks, setTasks] = useState<DesktopTaskForestTaskNode[]>([]);
42+
const [nodes, setNodes] = useState<DesktopTaskForestNode[]>([]);
4143
const mountedRef = useRef(true);
4244
const currentThreadRef = useRef<string | null>(threadId);
4345

@@ -54,7 +56,7 @@ export function ThreadTaskTreePopover({
5456

5557
const load = useCallback(async () => {
5658
if (!threadId) {
57-
setTasks([]);
59+
setNodes([]);
5860
return;
5961
}
6062
try {
@@ -64,7 +66,7 @@ export function ThreadTaskTreePopover({
6466
if (!mountedRef.current || currentThreadRef.current !== threadId) {
6567
return;
6668
}
67-
setTasks(visibleTaskTreeTasks(page.tasks));
69+
setNodes(page.tasks);
6870
} catch {
6971
/* leave previous state on transient errors */
7072
}
@@ -73,7 +75,7 @@ export function ThreadTaskTreePopover({
7375
// Reset + reload whenever the active thread changes so the list always
7476
// reflects the conversation currently open in the detail pane.
7577
useEffect(() => {
76-
setTasks([]);
78+
setNodes([]);
7779
void load();
7880
}, [load]);
7981

@@ -82,11 +84,11 @@ export function ThreadTaskTreePopover({
8284
return () => window.clearInterval(interval);
8385
}, [load]);
8486

85-
const rows = useMemo(() => buildTaskRows(tasks), [tasks]);
86-
const activeCount = useMemo(() => taskTreeBadgeCount(tasks), [tasks]);
87+
const rows = useMemo(() => buildTaskRows(nodes), [nodes]);
88+
const activeCount = useMemo(() => taskTreeBadgeCount(nodes), [nodes]);
8789

8890
// Nothing to show when this conversation has no anchored active task tree.
89-
if (!threadId || tasks.length === 0) {
91+
if (!threadId || rows.length === 0) {
9092
return null;
9193
}
9294

desktop/garyx-desktop/src/renderer/src/app-shell/components/thread-task-tree-popover-model.test.mjs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ function task(overrides) {
3939
};
4040
}
4141

42-
test("keeps task nodes regardless of status for server-pruned tree", () => {
43-
const threadRoot = {
42+
function threadRoot(overrides = {}) {
43+
return {
4444
kind: "thread",
4545
nodeId: "thread-root:thread::root",
4646
threadId: "thread::root",
@@ -54,7 +54,12 @@ test("keeps task nodes regardless of status for server-pruned tree", () => {
5454
runState: "idle",
5555
updatedAt: null,
5656
lastActiveAt: null,
57+
...overrides,
5758
};
59+
}
60+
61+
test("keeps task nodes regardless of status for server-pruned tree", () => {
62+
const root = threadRoot();
5863
const doneAncestor = task({ number: 1, status: "done" });
5964
const activeChild = task({
6065
number: 2,
@@ -65,14 +70,15 @@ test("keeps task nodes regardless of status for server-pruned tree", () => {
6570
});
6671

6772
assert.deepEqual(
68-
visibleTaskTreeTasks([threadRoot, doneAncestor, activeChild]),
73+
visibleTaskTreeTasks([root, doneAncestor, activeChild]),
6974
[doneAncestor, activeChild],
7075
);
7176
});
7277

7378
test("badge counts only active tasks", () => {
7479
assert.equal(
7580
taskTreeBadgeCount([
81+
threadRoot(),
7682
task({ number: 1, status: "done" }),
7783
task({ number: 2, status: "in_progress" }),
7884
task({ number: 3, status: "in_review" }),
@@ -82,6 +88,34 @@ test("badge counts only active tasks", () => {
8288
);
8389
});
8490

91+
test("rows build from mixed forest without rendering thread root row", () => {
92+
const root = threadRoot();
93+
const derivedRoot = task({
94+
number: 1,
95+
status: "in_progress",
96+
parentNodeId: root.nodeId,
97+
parentThreadId: root.threadId,
98+
});
99+
const grandchild = task({
100+
number: 2,
101+
status: "in_review",
102+
parentNodeId: derivedRoot.nodeId,
103+
parentTaskNumber: 1,
104+
parentThreadId: derivedRoot.threadId,
105+
});
106+
107+
assert.deepEqual(
108+
buildTaskRows([root, derivedRoot, grandchild]).map(({ task, depth }) => [
109+
task.number,
110+
depth,
111+
]),
112+
[
113+
[1, 0],
114+
[2, 1],
115+
],
116+
);
117+
});
118+
85119
test("current node is local to selected thread", () => {
86120
const current = task({ number: 2, threadId: "thread::current" });
87121
assert.equal(isCurrentTaskTreeNode(current, "thread::current"), true);

desktop/garyx-desktop/src/renderer/src/app-shell/components/thread-task-tree-popover-model.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ export function isActiveTaskStatus(status: DesktopTaskStatus): boolean {
2222
return status === "in_progress" || status === "in_review";
2323
}
2424

25-
export function taskTreeBadgeCount(tasks: DesktopTaskForestTaskNode[]): number {
26-
return tasks.filter((task) => isActiveTaskStatus(task.status)).length;
25+
export function taskTreeBadgeCount(nodes: DesktopTaskForestNode[]): number {
26+
return nodes.filter((node) => isTaskNode(node) && isActiveTaskStatus(node.status)).length;
2727
}
2828

2929
export function isCurrentTaskTreeNode(
@@ -76,30 +76,48 @@ export function taskStatusLabel(status: DesktopTaskStatus): string {
7676
/** Depth-order the tasks into a tree via parentNodeId; nodes whose parent
7777
* isn't in the set become roots (depth 0). */
7878
export function buildTaskRows(
79-
tasks: DesktopTaskForestTaskNode[],
79+
nodes: DesktopTaskForestNode[],
8080
): TaskTreeRow[] {
81-
const ids = new Set(tasks.map((task) => task.nodeId));
82-
const childrenByParent = new Map<string, DesktopTaskForestTaskNode[]>();
83-
for (const task of tasks) {
81+
const ids = new Set(nodes.map((node) => node.nodeId));
82+
const originalIndex = new Map(nodes.map((node, index) => [node.nodeId, index]));
83+
const childrenByParent = new Map<string, DesktopTaskForestNode[]>();
84+
for (const node of nodes) {
8485
const parent =
85-
task.parentNodeId && ids.has(task.parentNodeId) ? task.parentNodeId : "";
86+
node.kind === "task" && node.parentNodeId && ids.has(node.parentNodeId)
87+
? node.parentNodeId
88+
: "";
8689
const list = childrenByParent.get(parent) ?? [];
87-
list.push(task);
90+
list.push(node);
8891
childrenByParent.set(parent, list);
8992
}
9093
for (const list of childrenByParent.values()) {
91-
list.sort((a, b) => a.number - b.number);
94+
list.sort((a, b) => {
95+
if (a.kind === "task" && b.kind === "task") {
96+
return a.number - b.number;
97+
}
98+
if (a.kind === "thread" && b.kind === "task") {
99+
return -1;
100+
}
101+
if (a.kind === "task" && b.kind === "thread") {
102+
return 1;
103+
}
104+
return (originalIndex.get(a.nodeId) ?? 0) - (originalIndex.get(b.nodeId) ?? 0);
105+
});
92106
}
93107
const rows: TaskTreeRow[] = [];
94108
const visited = new Set<string>();
95109
const walk = (parent: string, depth: number) => {
96-
for (const task of childrenByParent.get(parent) ?? []) {
97-
if (visited.has(task.nodeId)) {
110+
for (const node of childrenByParent.get(parent) ?? []) {
111+
if (visited.has(node.nodeId)) {
98112
continue;
99113
}
100-
visited.add(task.nodeId);
101-
rows.push({ task, depth });
102-
walk(task.nodeId, Math.min(depth + 1, 4));
114+
visited.add(node.nodeId);
115+
if (node.kind === "thread") {
116+
walk(node.nodeId, depth);
117+
} else {
118+
rows.push({ task: node, depth });
119+
walk(node.nodeId, Math.min(depth + 1, 4));
120+
}
103121
}
104122
};
105123
walk("", 0);

0 commit comments

Comments
 (0)