santree 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -79,13 +79,26 @@ function buildRows(groups, flatIssues) {
79
79
  // Build a map from issue identifier to flat index
80
80
  const indexMap = new Map();
81
81
  flatIssues.forEach((di, i) => indexMap.set(di.issue.identifier, i));
82
+ function pushIssueWithChildren(di, depth) {
83
+ rows.push({
84
+ kind: "issue",
85
+ issue: di,
86
+ flatIndex: indexMap.get(di.issue.identifier) ?? -1,
87
+ depth,
88
+ });
89
+ if (di.children) {
90
+ for (const child of di.children) {
91
+ pushIssueWithChildren(child, depth + 1);
92
+ }
93
+ }
94
+ }
82
95
  for (const group of groups) {
83
96
  const totalIssues = group.statusGroups.reduce((sum, sg) => sum + sg.issues.length, 0);
84
97
  rows.push({ kind: "header", name: group.name, count: totalIssues });
85
98
  for (const sg of group.statusGroups) {
86
99
  rows.push({ kind: "status-header", name: sg.name, type: sg.type, count: sg.issues.length });
87
100
  for (const di of sg.issues) {
88
- rows.push({ kind: "issue", issue: di, flatIndex: indexMap.get(di.issue.identifier) ?? -1 });
101
+ pushIssueWithChildren(di, 0);
89
102
  }
90
103
  }
91
104
  }
@@ -115,7 +128,7 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
115
128
  if (row.kind === "status-header") {
116
129
  return (_jsx(Box, { children: _jsxs(Text, { color: stateColor(row.type, row.name), dimColor: true, children: [" ", row.name, " (", row.count, ")"] }) }, `sh-${i}`));
117
130
  }
118
- const { issue, flatIndex } = row;
131
+ const { issue, flatIndex, depth } = row;
119
132
  const selected = flatIndex === selectedIndex;
120
133
  const di = issue;
121
134
  const sc = stateColor(di.issue.state.type, di.issue.state.name);
@@ -126,10 +139,12 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
126
139
  const pr = prIndicator(di.pr);
127
140
  const prio = priorityIndicator(di.issue.priority);
128
141
  const cursor = selected ? ">" : " ";
129
- const title = di.issue.title.length > titleMaxWidth
130
- ? di.issue.title.slice(0, titleMaxWidth - 1) + "…"
142
+ const nestPrefix = depth > 0 ? " ".repeat(depth - 1) + "└ " : "";
143
+ const adjustedTitleWidth = Math.max(titleMaxWidth - nestPrefix.length, 5);
144
+ const title = di.issue.title.length > adjustedTitleWidth
145
+ ? di.issue.title.slice(0, adjustedTitleWidth - 1) + "…"
131
146
  : di.issue.title;
132
147
  const bg = selected ? "#1e3a5f" : undefined;
133
- return (_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsxs(Text, { backgroundColor: bg, color: prio.color, children: [" ", prio.text] }), _jsx(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: di.issue.identifier.padEnd(10) }), _jsx(Text, { backgroundColor: bg, color: selected ? "white" : undefined, bold: selected, children: title.padEnd(titleMaxWidth) }), _jsx(Text, { backgroundColor: bg, color: selected ? (sess.color === "gray" ? "gray" : sess.color) : sess.color, children: sess.text.padStart(sessionColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (pr.color === "gray" ? "gray" : pr.color) : pr.color, children: pr.text.padStart(prColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, di.issue.identifier));
148
+ return (_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsxs(Text, { backgroundColor: bg, color: prio.color, children: [" ", prio.text] }), _jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [nestPrefix, di.issue.identifier.padEnd(10)] }), _jsx(Text, { backgroundColor: bg, color: selected ? "white" : undefined, bold: selected, children: title.padEnd(adjustedTitleWidth) }), _jsx(Text, { backgroundColor: bg, color: selected ? (sess.color === "gray" ? "gray" : sess.color) : sess.color, children: sess.text.padStart(sessionColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (pr.color === "gray" ? "gray" : pr.color) : pr.color, children: pr.text.padStart(prColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, di.issue.identifier));
134
149
  }) }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: footerRule }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { color: "white", children: " Navigate" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "Shift + \u2191\u2193" }), _jsx(Text, { color: "white", children: " Scroll detail" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "E" }), _jsx(Text, { color: "white", children: " Workspace" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "R" }), _jsx(Text, { color: "white", children: " Refresh" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { color: "white", children: " Quit" })] })] })] }));
135
150
  }
@@ -0,0 +1,9 @@
1
+ import type { EnrichedReviewPR } from "./types.js";
2
+ interface Props {
3
+ item: EnrichedReviewPR | null;
4
+ scrollOffset: number;
5
+ height: number;
6
+ width: number;
7
+ }
8
+ export default function ReviewDetailPanel({ item, scrollOffset, height, width }: Props): import("react/jsx-runtime").JSX.Element;
9
+ export {};
@@ -0,0 +1,166 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ function relativeTime(dateStr) {
4
+ const now = Date.now();
5
+ const then = new Date(dateStr).getTime();
6
+ const diffMs = now - then;
7
+ const minutes = Math.floor(diffMs / 60_000);
8
+ if (minutes < 1)
9
+ return "just now";
10
+ if (minutes < 60)
11
+ return `${minutes}m ago`;
12
+ const hours = Math.floor(minutes / 60);
13
+ if (hours < 24)
14
+ return `${hours}h ago`;
15
+ const days = Math.floor(hours / 24);
16
+ if (days < 30)
17
+ return `${days}d ago`;
18
+ const months = Math.floor(days / 30);
19
+ return `${months}mo ago`;
20
+ }
21
+ function buildActions(item) {
22
+ const items = [];
23
+ if (item.worktree) {
24
+ items.push({ key: "r", label: "AI Review", color: "cyan" });
25
+ items.push({ key: "e", label: "Editor", color: "cyan" });
26
+ }
27
+ else {
28
+ items.push({ key: "w", label: "Checkout", color: "cyan" });
29
+ }
30
+ items.push({ key: "o", label: "Open PR", color: "gray" });
31
+ if (item.worktree) {
32
+ items.push({ key: "d", label: "Remove", color: "red" });
33
+ }
34
+ return items;
35
+ }
36
+ export default function ReviewDetailPanel({ item, scrollOffset, height, width }) {
37
+ if (!item) {
38
+ return (_jsx(Box, { width: width, height: height, justifyContent: "center", alignItems: "center", children: _jsx(Text, { dimColor: true, children: "No PR selected" }) }));
39
+ }
40
+ const { pr } = item;
41
+ const lines = [];
42
+ const rule = "\u2500".repeat(width);
43
+ // ── Hero ──────────────────────────────────────────────────────────
44
+ lines.push({ text: `#${pr.number} ${pr.title}`, bold: true });
45
+ const meta = [`by ${pr.author.login}`];
46
+ if (pr.isDraft)
47
+ meta.push("draft");
48
+ meta.push(relativeTime(pr.updatedAt));
49
+ lines.push({ text: meta.join(" \u00b7 "), color: "cyan" });
50
+ // ── Changes ──────────────────────────────────────────────────────
51
+ if (item.changedFiles > 0) {
52
+ lines.push({
53
+ text: `${item.changedFiles} files +${item.additions} -${item.deletions}`,
54
+ color: "green",
55
+ });
56
+ }
57
+ // ── Branch ───────────────────────────────────────────────────────
58
+ if (item.branch) {
59
+ lines.push({ text: rule, dim: true });
60
+ lines.push({ text: "BRANCH", dim: true });
61
+ lines.push({ text: ` ${item.branch}` });
62
+ if (item.baseBranch) {
63
+ lines.push({ text: ` base: ${item.baseBranch}`, dim: true });
64
+ }
65
+ }
66
+ // ── Worktree ─────────────────────────────────────────────────────
67
+ if (item.worktree) {
68
+ lines.push({ text: rule, dim: true });
69
+ lines.push({ text: "WORKTREE", dim: true });
70
+ lines.push({ text: ` ${item.worktree.path}`, dim: true });
71
+ const statusParts = [];
72
+ if (item.worktree.dirty)
73
+ statusParts.push("dirty");
74
+ if (item.worktree.commitsAhead > 0)
75
+ statusParts.push(`+${item.worktree.commitsAhead} ahead`);
76
+ if (statusParts.length > 0) {
77
+ lines.push({ text: ` ${statusParts.join(" ")}`, color: "yellow" });
78
+ }
79
+ else {
80
+ lines.push({ text: " \u2713 clean", color: "green" });
81
+ }
82
+ }
83
+ // ── Description ──────────────────────────────────────────────────
84
+ if (item.body) {
85
+ lines.push({ text: rule, dim: true });
86
+ lines.push({ text: "DESCRIPTION", dim: true });
87
+ lines.push({ text: "" });
88
+ for (const line of item.body.trimEnd().split("\n")) {
89
+ lines.push({ text: line });
90
+ }
91
+ lines.push({ text: "" });
92
+ }
93
+ // ── Checks ───────────────────────────────────────────────────────
94
+ if (item.checks && item.checks.length > 0) {
95
+ const passCount = item.checks.filter((c) => c.bucket === "pass").length;
96
+ lines.push({ text: rule, dim: true });
97
+ lines.push({ text: `CHECKS ${passCount}/${item.checks.length} passing`, dim: true });
98
+ for (const check of item.checks) {
99
+ if (check.bucket === "pass") {
100
+ lines.push({ text: ` \u2713 ${check.name}`, color: "green" });
101
+ }
102
+ else if (check.bucket === "fail") {
103
+ const desc = check.description ? ` \u2014 ${check.description}` : "";
104
+ lines.push({ text: ` \u2717 ${check.name}${desc}`, color: "red" });
105
+ }
106
+ else {
107
+ lines.push({ text: ` \u25cf ${check.name} (pending)`, color: "yellow" });
108
+ }
109
+ }
110
+ }
111
+ // ── Reviews ──────────────────────────────────────────────────────
112
+ if (item.reviews && item.reviews.length > 0) {
113
+ lines.push({ text: rule, dim: true });
114
+ lines.push({ text: "REVIEWS", dim: true });
115
+ for (const review of item.reviews) {
116
+ const author = review.author.login;
117
+ const rc = review.state === "APPROVED"
118
+ ? "green"
119
+ : review.state === "CHANGES_REQUESTED"
120
+ ? "red"
121
+ : "yellow";
122
+ lines.push({ text: ` ${author} ${review.state}`, color: rc });
123
+ }
124
+ }
125
+ // ── Comments ─────────────────────────────────────────────────────
126
+ if (item.comments && item.comments.length > 0) {
127
+ lines.push({ text: rule, dim: true });
128
+ lines.push({ text: `COMMENTS ${item.comments.length}`, dim: true });
129
+ // Show last 5 comments
130
+ const recent = item.comments.slice(-5);
131
+ for (const comment of recent) {
132
+ lines.push({ text: "" });
133
+ lines.push({
134
+ text: ` ${comment.author} ${relativeTime(comment.createdAt)}`,
135
+ color: "cyan",
136
+ });
137
+ // Truncate long comments to ~4 lines
138
+ const bodyLines = comment.body.trimEnd().split("\n");
139
+ const maxLines = 4;
140
+ for (let i = 0; i < Math.min(bodyLines.length, maxLines); i++) {
141
+ lines.push({ text: ` ${bodyLines[i]}` });
142
+ }
143
+ if (bodyLines.length > maxLines) {
144
+ lines.push({ text: ` +${bodyLines.length - maxLines} more lines`, dim: true });
145
+ }
146
+ }
147
+ }
148
+ // ── Build actions footer ─────────────────────────────────────────
149
+ const actionItems = buildActions(item);
150
+ const actionsHeight = 2; // separator + action row
151
+ const scrollableHeight = height - actionsHeight;
152
+ const totalLines = lines.length;
153
+ const canScroll = totalLines > scrollableHeight;
154
+ const contentRows = canScroll ? scrollableHeight - 2 : scrollableHeight;
155
+ const clampedOffset = Math.min(scrollOffset, Math.max(0, totalLines - contentRows));
156
+ const visible = lines.slice(clampedOffset, clampedOffset + contentRows);
157
+ let scrollArrow = null;
158
+ if (canScroll) {
159
+ const atTop = clampedOffset === 0;
160
+ const atBottom = clampedOffset + contentRows >= totalLines;
161
+ scrollArrow = atTop ? "\u2193 scroll" : atBottom ? "\u2191 scroll" : "\u2191\u2193 scroll";
162
+ }
163
+ // Truncate lines to panel width to prevent overflow into left pane
164
+ const clamp = (text) => text.length > width ? text.slice(0, width - 1) + "\u2026" : text;
165
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, overflowX: "hidden", children: [visible.map((line, i) => (_jsx(Box, { children: _jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: line.text ? clamp(line.text) : " " }) }, i))), scrollArrow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: " " }) })), scrollArrow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: scrollArrow }) })), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: rule }) }), _jsx(Box, { children: actionItems.map((item, j) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: item.color, bold: true, children: item.key }), _jsxs(Text, { color: item.color === "gray" ? "gray" : "white", children: [" ", item.label] })] }, j))) })] }));
166
+ }
@@ -0,0 +1,11 @@
1
+ import type { EnrichedReviewPR } from "./types.js";
2
+ interface Props {
3
+ flatReviews: EnrichedReviewPR[];
4
+ selectedIndex: number;
5
+ scrollOffset: number;
6
+ height: number;
7
+ width: number;
8
+ }
9
+ export declare function getReviewListRowCount(flatReviews: EnrichedReviewPR[]): number;
10
+ export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, }: Props): import("react/jsx-runtime").JSX.Element;
11
+ export {};
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ function checksIndicator(checks) {
4
+ if (!checks || checks.length === 0)
5
+ return { text: "-", color: "gray" };
6
+ if (checks.some((c) => c.bucket === "fail"))
7
+ return { text: "\u2717", color: "red" };
8
+ if (checks.every((c) => c.bucket === "pass"))
9
+ return { text: "\u2713", color: "green" };
10
+ return { text: "\u25cf", color: "yellow" };
11
+ }
12
+ const FOOTER_HEIGHT = 2;
13
+ const HEADER_ROWS = 1;
14
+ export function getReviewListRowCount(flatReviews) {
15
+ return HEADER_ROWS + flatReviews.length;
16
+ }
17
+ export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, }) {
18
+ const listHeight = height - FOOTER_HEIGHT;
19
+ const numColWidth = 6;
20
+ const authorColWidth = 12;
21
+ const changesColWidth = 10;
22
+ const checksColWidth = 2;
23
+ const fixedWidth = 2 + numColWidth + 1 + authorColWidth + 1 + changesColWidth + 1 + checksColWidth;
24
+ const titleMaxWidth = Math.max(width - fixedWidth, 10);
25
+ const footerRule = "\u2500".repeat(width);
26
+ const totalRows = HEADER_ROWS + flatReviews.length;
27
+ const visibleStart = scrollOffset;
28
+ const visibleEnd = Math.min(visibleStart + listHeight, totalRows);
29
+ const rows = [];
30
+ for (let rowIdx = visibleStart; rowIdx < visibleEnd; rowIdx++) {
31
+ if (rowIdx === 0) {
32
+ rows.push(_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "#".padEnd(numColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "".padEnd(titleMaxWidth) }), _jsx(Text, { dimColor: true, children: "author".padStart(authorColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "changes".padStart(changesColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "ci".padStart(checksColWidth) })] }, "col-header"));
33
+ continue;
34
+ }
35
+ const flatIndex = rowIdx - HEADER_ROWS;
36
+ const item = flatReviews[flatIndex];
37
+ if (!item)
38
+ continue;
39
+ const { pr } = item;
40
+ const selected = flatIndex === selectedIndex;
41
+ const cursor = selected ? ">" : " ";
42
+ const num = `#${pr.number}`;
43
+ const title = pr.title.length > titleMaxWidth ? pr.title.slice(0, titleMaxWidth - 1) + "\u2026" : pr.title;
44
+ const author = pr.author.login.length > authorColWidth
45
+ ? pr.author.login.slice(0, authorColWidth - 1) + "\u2026"
46
+ : pr.author.login;
47
+ const changes = `+${item.additions} -${item.deletions}`;
48
+ const ci = checksIndicator(item.checks);
49
+ const bg = selected ? "#1e3a5f" : undefined;
50
+ rows.push(_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: pr.isDraft ? "gray" : "green", children: num.padEnd(numColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? "white" : undefined, bold: selected, children: title.padEnd(titleMaxWidth) }), _jsx(Text, { backgroundColor: bg, dimColor: true, children: author.padStart(authorColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, children: [_jsx(Text, { color: "green", children: `+${item.additions}` }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { color: "red", children: `-${item.deletions}` }), "".padStart(Math.max(0, changesColWidth - changes.length))] }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, `${pr.number}`));
51
+ }
52
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Box, { flexDirection: "column", height: listHeight, children: rows }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: footerRule }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { color: "white", children: " Navigate" }), " ", _jsxs(Text, { color: "cyan", bold: true, children: ["Shift + ", "\u2191\u2193"] }), _jsx(Text, { color: "white", children: " Scroll detail" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "o" }), _jsx(Text, { color: "white", children: " Open PR" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "Tab" }), _jsx(Text, { color: "white", children: " Issues" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { color: "white", children: " Quit" })] })] })] }));
53
+ }
@@ -1,5 +1,8 @@
1
- import type { DashboardIssue, ProjectGroup } from "./types.js";
1
+ import type { DashboardIssue, ProjectGroup, EnrichedReviewPR } from "./types.js";
2
2
  export declare function loadDashboardData(repoRoot: string): Promise<{
3
3
  groups: ProjectGroup[];
4
4
  flatIssues: DashboardIssue[];
5
5
  }>;
6
+ export declare function loadReviewsData(repoRoot: string): Promise<{
7
+ flatReviews: EnrichedReviewPR[];
8
+ }>;
@@ -1,5 +1,5 @@
1
1
  import { listWorktrees, extractTicketId, getBaseBranch, readAllMetadata, readSessionState, isSessionAliveInTmux, clearSessionState, getGitStatusAsync, getCommitsAheadAsync, } from "../git.js";
2
- import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, } from "../github.js";
2
+ import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, } from "../github.js";
3
3
  import { fetchAssignedIssues } from "../linear.js";
4
4
  export async function loadDashboardData(repoRoot) {
5
5
  // Fetch issues and worktrees in parallel
@@ -129,9 +129,37 @@ export async function loadDashboardData(repoRoot) {
129
129
  reviews: reviewsInfo,
130
130
  };
131
131
  }));
132
- // Group by project
132
+ // ── Compute parent-child relationships ──────────────────────────
133
+ // Build a map from worktree branch → DashboardIssue
134
+ const allIssues = [...enriched, ...orphans];
135
+ const branchToIssue = new Map();
136
+ for (const di of allIssues) {
137
+ if (di.worktree)
138
+ branchToIssue.set(di.worktree.branch, di);
139
+ }
140
+ // For each issue with a worktree, check if its base_branch matches another issue's branch
141
+ const childTicketIds = new Set();
142
+ for (const di of allIssues) {
143
+ if (!di.worktree)
144
+ continue;
145
+ const ticketId = di.issue.identifier;
146
+ const baseBranch = metadata[ticketId]?.base_branch;
147
+ if (!baseBranch)
148
+ continue; // no custom base = branched from default, not a child
149
+ const parent = branchToIssue.get(baseBranch);
150
+ if (!parent || parent === di)
151
+ continue;
152
+ di.parentTicketId = parent.issue.identifier;
153
+ if (!parent.children)
154
+ parent.children = [];
155
+ parent.children.push(di);
156
+ childTicketIds.add(ticketId);
157
+ }
158
+ // Group by project (excluding children — they'll appear nested under parents)
133
159
  const groupMap = new Map();
134
160
  for (const di of enriched) {
161
+ if (childTicketIds.has(di.issue.identifier))
162
+ continue;
135
163
  const key = di.issue.projectName ?? "No Project";
136
164
  const list = groupMap.get(key) ?? [];
137
165
  list.push(di);
@@ -169,8 +197,9 @@ export async function loadDashboardData(repoRoot) {
169
197
  statusGroups,
170
198
  };
171
199
  });
172
- // Append orphaned worktrees as a separate group at the bottom
173
- if (orphans.length > 0) {
200
+ // Append orphaned worktrees as a separate group at the bottom (excluding children)
201
+ const topLevelOrphans = orphans.filter((di) => !childTicketIds.has(di.issue.identifier));
202
+ if (topLevelOrphans.length > 0) {
174
203
  groups.push({
175
204
  name: "Orphaned Worktrees",
176
205
  id: null,
@@ -178,11 +207,89 @@ export async function loadDashboardData(repoRoot) {
178
207
  {
179
208
  name: "Orphaned",
180
209
  type: "orphaned",
181
- issues: orphans,
210
+ issues: topLevelOrphans,
182
211
  },
183
212
  ],
184
213
  });
185
214
  }
186
- const flatIssues = groups.flatMap((g) => g.statusGroups.flatMap((sg) => sg.issues));
215
+ // Flatten with children inserted right after their parent
216
+ function flattenWithChildren(di) {
217
+ const result = [di];
218
+ if (di.children) {
219
+ for (const child of di.children) {
220
+ result.push(...flattenWithChildren(child));
221
+ }
222
+ }
223
+ return result;
224
+ }
225
+ const flatIssues = groups.flatMap((g) => g.statusGroups.flatMap((sg) => sg.issues.flatMap(flattenWithChildren)));
187
226
  return { groups, flatIssues };
188
227
  }
228
+ export async function loadReviewsData(repoRoot) {
229
+ const repo = await getRepoNameAsync();
230
+ if (!repo)
231
+ return { flatReviews: [] };
232
+ const prs = await getReviewRequestedPRsAsync(repo);
233
+ prs.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
234
+ // Build worktree map for matching PR branches to local worktrees
235
+ const worktrees = listWorktrees();
236
+ const branchToWt = new Map();
237
+ for (const wt of worktrees) {
238
+ if (wt.branch)
239
+ branchToWt.set(wt.branch, { path: wt.path, branch: wt.branch });
240
+ }
241
+ const metadata = readAllMetadata(repoRoot);
242
+ // Enrich each PR in parallel
243
+ const enriched = await Promise.all(prs.map(async (pr) => {
244
+ const [view, checks, reviews, comments] = await Promise.all([
245
+ getPRViewAsync(pr.number),
246
+ getPRChecksAsync(String(pr.number)),
247
+ getPRReviewsAsync(String(pr.number)),
248
+ getPRConversationCommentsAsync(String(pr.number)),
249
+ ]);
250
+ // Check if we have a local worktree for this PR's branch
251
+ let worktreeInfo = null;
252
+ const branch = view?.headRefName ?? null;
253
+ if (branch) {
254
+ const wt = branchToWt.get(branch);
255
+ if (wt) {
256
+ const ticketId = extractTicketId(branch);
257
+ const base = getBaseBranch(branch);
258
+ const [gitStatusOutput, ahead] = await Promise.all([
259
+ getGitStatusAsync(wt.path),
260
+ getCommitsAheadAsync(wt.path, base),
261
+ ]);
262
+ let sessState = ticketId ? readSessionState(repoRoot, ticketId) : null;
263
+ if (sessState && ticketId && !isSessionAliveInTmux(ticketId)) {
264
+ clearSessionState(repoRoot, ticketId);
265
+ sessState = null;
266
+ }
267
+ const ss = sessState?.state ?? null;
268
+ worktreeInfo = {
269
+ path: wt.path,
270
+ branch: wt.branch,
271
+ dirty: Boolean(gitStatusOutput),
272
+ commitsAhead: ahead,
273
+ sessionId: ticketId ? (metadata[ticketId]?.session_id ?? null) : null,
274
+ gitStatus: gitStatusOutput,
275
+ sessionState: ss === "exited" ? null : ss,
276
+ sessionMessage: sessState?.message ?? null,
277
+ };
278
+ }
279
+ }
280
+ return {
281
+ pr,
282
+ body: view?.body ?? null,
283
+ branch,
284
+ baseBranch: view?.baseRefName ?? null,
285
+ additions: view?.additions ?? 0,
286
+ deletions: view?.deletions ?? 0,
287
+ changedFiles: view?.changedFiles ?? 0,
288
+ checks,
289
+ reviews,
290
+ comments,
291
+ worktree: worktreeInfo,
292
+ };
293
+ }));
294
+ return { flatReviews: enriched };
295
+ }
@@ -1,4 +1,4 @@
1
- import type { PRInfo, PRCheck, PRReview } from "../github.js";
1
+ import type { PRInfo, PRCheck, PRReview, PRConversationComment, SearchPR } from "../github.js";
2
2
  export interface LinearAssignedIssue {
3
3
  identifier: string;
4
4
  title: string;
@@ -30,6 +30,8 @@ export interface DashboardIssue {
30
30
  pr: PRInfo | null;
31
31
  checks: PRCheck[] | null;
32
32
  reviews: PRReview[] | null;
33
+ parentTicketId?: string;
34
+ children?: DashboardIssue[];
33
35
  }
34
36
  export interface StatusGroup {
35
37
  name: string;
@@ -41,15 +43,35 @@ export interface ProjectGroup {
41
43
  id: string | null;
42
44
  statusGroups: StatusGroup[];
43
45
  }
44
- export type ActionOverlay = "mode-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | null;
46
+ export type ReviewPR = SearchPR;
47
+ export interface EnrichedReviewPR {
48
+ pr: SearchPR;
49
+ body: string | null;
50
+ branch: string | null;
51
+ baseBranch: string | null;
52
+ additions: number;
53
+ deletions: number;
54
+ changedFiles: number;
55
+ checks: PRCheck[] | null;
56
+ reviews: PRReview[] | null;
57
+ comments: PRConversationComment[] | null;
58
+ worktree: WorktreeInfo | null;
59
+ }
60
+ export type DashboardTab = "issues" | "reviews";
61
+ export type ActionOverlay = "mode-select" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | null;
45
62
  export type CommitPhase = "idle" | "confirm-stage" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
46
63
  export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "creating" | "done" | "error";
47
64
  export interface DashboardState {
65
+ activeTab: DashboardTab;
48
66
  groups: ProjectGroup[];
49
67
  flatIssues: DashboardIssue[];
50
68
  selectedIndex: number;
51
69
  listScrollOffset: number;
52
70
  detailScrollOffset: number;
71
+ flatReviews: EnrichedReviewPR[];
72
+ reviewSelectedIndex: number;
73
+ reviewListScrollOffset: number;
74
+ reviewDetailScrollOffset: number;
53
75
  loading: boolean;
54
76
  refreshing: boolean;
55
77
  error: string | null;
@@ -75,6 +97,9 @@ export interface DashboardState {
75
97
  prCreateBody: string | null;
76
98
  prCreateTitle: string | null;
77
99
  setupMode: "plan" | "implement" | null;
100
+ baseSelectOptions: string[];
101
+ baseSelectIndex: number;
102
+ baseSelectChosen: string | null;
78
103
  }
79
104
  export type DashboardAction = {
80
105
  type: "SET_DATA";
@@ -164,6 +189,32 @@ export type DashboardAction = {
164
189
  mode: "plan" | "implement";
165
190
  } | {
166
191
  type: "SETUP_CONFIRM_DONE";
192
+ } | {
193
+ type: "BASE_SELECT_SHOW";
194
+ options: string[];
195
+ } | {
196
+ type: "BASE_SELECT_MOVE";
197
+ index: number;
198
+ } | {
199
+ type: "BASE_SELECT_CONFIRM";
200
+ chosen: string;
201
+ } | {
202
+ type: "BASE_SELECT_DONE";
203
+ } | {
204
+ type: "SET_TAB";
205
+ tab: DashboardTab;
206
+ } | {
207
+ type: "SET_REVIEWS_DATA";
208
+ flatReviews: EnrichedReviewPR[];
209
+ } | {
210
+ type: "REVIEW_SELECT";
211
+ index: number;
212
+ } | {
213
+ type: "REVIEW_SCROLL_LIST";
214
+ offset: number;
215
+ } | {
216
+ type: "REVIEW_SCROLL_DETAIL";
217
+ offset: number;
167
218
  };
168
219
  export declare const initialState: DashboardState;
169
220
  export declare function reducer(state: DashboardState, action: DashboardAction): DashboardState;
@@ -1,10 +1,15 @@
1
1
  // ── State management ──────────────────────────────────────────────────
2
2
  export const initialState = {
3
+ activeTab: "issues",
3
4
  groups: [],
4
5
  flatIssues: [],
5
6
  selectedIndex: 0,
6
7
  listScrollOffset: 0,
7
8
  detailScrollOffset: 0,
9
+ flatReviews: [],
10
+ reviewSelectedIndex: 0,
11
+ reviewListScrollOffset: 0,
12
+ reviewDetailScrollOffset: 0,
8
13
  loading: true,
9
14
  refreshing: false,
10
15
  error: null,
@@ -30,6 +35,9 @@ export const initialState = {
30
35
  prCreateBody: null,
31
36
  prCreateTitle: null,
32
37
  setupMode: null,
38
+ baseSelectOptions: [],
39
+ baseSelectIndex: 0,
40
+ baseSelectChosen: null,
33
41
  };
34
42
  export function reducer(state, action) {
35
43
  switch (action.type) {
@@ -81,9 +89,21 @@ export function reducer(state, action) {
81
89
  case "CREATION_LOG":
82
90
  return { ...state, creationLogs: state.creationLogs + action.logs };
83
91
  case "CREATION_DONE":
84
- return { ...state, creatingForTicket: null, creationLogs: "", creationError: null };
92
+ return {
93
+ ...state,
94
+ creatingForTicket: null,
95
+ creationLogs: "",
96
+ creationError: null,
97
+ baseSelectChosen: null,
98
+ };
85
99
  case "CREATION_ERROR":
86
- return { ...state, creationError: action.error, creatingForTicket: null, creationLogs: "" };
100
+ return {
101
+ ...state,
102
+ creationError: action.error,
103
+ creatingForTicket: null,
104
+ creationLogs: "",
105
+ baseSelectChosen: null,
106
+ };
87
107
  case "DELETE_START":
88
108
  return { ...state, deletingForTicket: action.ticketId };
89
109
  case "DELETE_DONE":
@@ -176,6 +196,52 @@ export function reducer(state, action) {
176
196
  overlay: null,
177
197
  setupMode: null,
178
198
  };
199
+ case "BASE_SELECT_SHOW":
200
+ return {
201
+ ...state,
202
+ overlay: "base-select",
203
+ baseSelectOptions: action.options,
204
+ baseSelectIndex: 0,
205
+ baseSelectChosen: null,
206
+ };
207
+ case "BASE_SELECT_MOVE":
208
+ return { ...state, baseSelectIndex: action.index };
209
+ case "BASE_SELECT_CONFIRM":
210
+ return {
211
+ ...state,
212
+ overlay: null,
213
+ baseSelectChosen: action.chosen,
214
+ };
215
+ case "BASE_SELECT_DONE":
216
+ return {
217
+ ...state,
218
+ overlay: null,
219
+ baseSelectOptions: [],
220
+ baseSelectIndex: 0,
221
+ };
222
+ case "SET_TAB":
223
+ return { ...state, activeTab: action.tab };
224
+ case "SET_REVIEWS_DATA": {
225
+ const prevNum = state.flatReviews[state.reviewSelectedIndex]?.pr.number;
226
+ let newIdx = 0;
227
+ if (prevNum !== undefined) {
228
+ const found = action.flatReviews.findIndex((p) => p.pr.number === prevNum);
229
+ if (found >= 0)
230
+ newIdx = found;
231
+ }
232
+ return {
233
+ ...state,
234
+ flatReviews: action.flatReviews,
235
+ reviewSelectedIndex: newIdx,
236
+ reviewDetailScrollOffset: 0,
237
+ };
238
+ }
239
+ case "REVIEW_SELECT":
240
+ return { ...state, reviewSelectedIndex: action.index, reviewDetailScrollOffset: 0 };
241
+ case "REVIEW_SCROLL_LIST":
242
+ return { ...state, reviewListScrollOffset: action.offset };
243
+ case "REVIEW_SCROLL_DETAIL":
244
+ return { ...state, reviewDetailScrollOffset: action.offset };
179
245
  default:
180
246
  return state;
181
247
  }