santree 0.7.0 → 0.7.3

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.
package/dist/lib/ai.d.ts CHANGED
@@ -88,6 +88,19 @@ export declare function runAgentAsync(prompt: string, opts?: {
88
88
  * Clean up cached image downloads for an issue identifier on the active tracker.
89
89
  */
90
90
  export declare function cleanupImages(ticketId: string): void;
91
+ /**
92
+ * Ask Claude a clarifying question about a triage issue. The full issue —
93
+ * description plus every comment — is rendered via the shared `ticket.njk`
94
+ * template and injected into `ask.njk`. Read-only codebase tools are granted
95
+ * (Read for downloaded issue images, Grep/Glob so Claude can judge whether the
96
+ * issue is fixable against the real code). Runs non-interactively and returns
97
+ * the captured answer text. Async so the Ink dashboard keeps animating.
98
+ */
99
+ export declare function askTicketQuestion(opts: {
100
+ ticket: Issue;
101
+ trackerName: string;
102
+ question: string;
103
+ }): Promise<RunAgentResult>;
91
104
  export interface FillCommitOpts {
92
105
  branch: string;
93
106
  ticketId: string | null;
package/dist/lib/ai.js CHANGED
@@ -267,6 +267,22 @@ export function cleanupImages(ticketId) {
267
267
  const repoRoot = findMainRepoRoot();
268
268
  getIssueTracker(repoRoot).cleanupCache(ticketId);
269
269
  }
270
+ /**
271
+ * Ask Claude a clarifying question about a triage issue. The full issue —
272
+ * description plus every comment — is rendered via the shared `ticket.njk`
273
+ * template and injected into `ask.njk`. Read-only codebase tools are granted
274
+ * (Read for downloaded issue images, Grep/Glob so Claude can judge whether the
275
+ * issue is fixable against the real code). Runs non-interactively and returns
276
+ * the captured answer text. Async so the Ink dashboard keeps animating.
277
+ */
278
+ export async function askTicketQuestion(opts) {
279
+ const prompt = renderPrompt("ask", {
280
+ ticket_id: opts.ticket.identifier,
281
+ ticket_content: renderTicket(opts.ticket, opts.trackerName),
282
+ user_question: opts.question,
283
+ });
284
+ return runAgentAsync(prompt, { allowedTools: ["Read", "Grep", "Glob"] });
285
+ }
270
286
  /**
271
287
  * Generate a short imperative commit message from a staged diff.
272
288
  * Async so callers (the Ink dashboard, the CLI commit flow) keep the
@@ -1,4 +1,4 @@
1
- import type { DashboardIssue, DashboardTab } from "./types.js";
1
+ import type { Comment, DashboardIssue, DashboardTab, DeleteStatus } from "./types.js";
2
2
  interface Props {
3
3
  issue: DashboardIssue | null;
4
4
  scrollOffset: number;
@@ -6,6 +6,22 @@ interface Props {
6
6
  width: number;
7
7
  creatingForTicket: string | null;
8
8
  creationLogs: string;
9
+ /** Deletion progress for the selected worktree, when one is being removed. */
10
+ deleteStatus?: DeleteStatus;
11
+ /** Triage mode: hide worktree/PR/checks sections (they never apply to an
12
+ * inbox issue) and show the discussion instead. */
13
+ triage?: boolean;
14
+ /** Comments for the selected triage issue. `undefined` = not yet loaded
15
+ * (shows "loading…"); an array (possibly empty) = loaded. Only consulted in
16
+ * triage mode. */
17
+ comments?: Comment[];
18
+ /** Compact triage on-call summary, shown as the first line in triage mode. */
19
+ onCall?: {
20
+ currentName: string | null;
21
+ currentIsMe: boolean;
22
+ /** Formatted start of the viewer's next shift (e.g. "Jun 5"), if any. */
23
+ myNext: string | null;
24
+ };
9
25
  }
10
26
  export type IssueActionItem = {
11
27
  key: string;
@@ -22,5 +38,5 @@ export declare function buildIssueActions(di: DashboardIssue, trackerName: strin
22
38
  tab?: DashboardTab;
23
39
  canMutate?: boolean;
24
40
  }): IssueActionItem[];
25
- export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, }: Props): import("react/jsx-runtime").JSX.Element;
41
+ export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, triage, comments, onCall, }: Props): import("react/jsx-runtime").JSX.Element;
26
42
  export {};
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import { formatSla, isSnoozed } from "./sla.js";
4
+ import { issueReadiness } from "../trackers/types.js";
3
5
  function stateColor(type) {
4
6
  switch (type) {
5
7
  case "started":
@@ -77,6 +79,18 @@ export function buildIssueActions(di, trackerName, opts) {
77
79
  }
78
80
  return items;
79
81
  }
82
+ // Triage tab = the incoming inbox. Read the discussion, ask Claude a
83
+ // clarifying question, and — once it looks fixable — send it to a tree
84
+ // (`w`, same worktree-creation flow as the Issues tab).
85
+ if (opts?.tab === "triage") {
86
+ items.push({ key: "w", label: "Send to tree", color: "cyan" });
87
+ items.push({ key: "a", label: "Ask", color: "cyan" });
88
+ items.push({ key: "s", label: "Schedule", color: "cyan" });
89
+ if (issue.url) {
90
+ items.push({ key: "o", label: trackerName, color: "gray" });
91
+ }
92
+ return items;
93
+ }
80
94
  // The synthetic "Main repo" row is special: no PR/Switch/Resume/Remove,
81
95
  // no work-launching (you're already on it). Only commit / diff /
82
96
  // editor — the actions that make sense for "I have changes in main and
@@ -137,7 +151,22 @@ function sectionHeader(icon, label, iconColor = "cyan") {
137
151
  ],
138
152
  };
139
153
  }
140
- export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, }) {
154
+ export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, triage = false, comments, onCall, }) {
155
+ // Show deletion progress when the selected worktree is being removed.
156
+ if (issue && deleteStatus) {
157
+ const logLines = deleteStatus.logs.split("\n");
158
+ const contentRows = height - 1;
159
+ const startIdx = Math.max(0, logLines.length - contentRows);
160
+ const visible = logLines.slice(startIdx, startIdx + contentRows);
161
+ const clampLine = (s) => s.length > width ? s.slice(0, Math.max(0, width - 1)) + "…" : s;
162
+ const headerColor = deleteStatus.phase === "error" ? "red" : "yellow";
163
+ const header = deleteStatus.phase === "error"
164
+ ? `Failed to remove ${issue.issue.identifier}`
165
+ : deleteStatus.phase === "done"
166
+ ? `Removed ${issue.issue.identifier}`
167
+ : `Removing worktree for ${issue.issue.identifier}…`;
168
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { color: deleteStatus.phase === "done" ? "green" : headerColor, bold: true, children: clampLine(header) }), visible.map((line, i) => (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: clampLine(line) }) }, i))), deleteStatus.phase === "error" && deleteStatus.error ? (_jsx(Text, { color: "red", children: clampLine(deleteStatus.error) })) : null] }));
169
+ }
141
170
  // Show creation logs when selected issue is being created
142
171
  if (issue && issue.issue.identifier === creatingForTicket) {
143
172
  const logLines = creationLogs.split("\n");
@@ -157,20 +186,62 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
157
186
  const lines = [];
158
187
  const rule = "─".repeat(width);
159
188
  const ruleLine = { text: rule, dim: true };
189
+ // ── Triage on-call (compact) ───────────────────────────────────────
190
+ // First line in triage mode: who's on call now + when the viewer is next up.
191
+ // The full rotation lives behind the [s] schedule overlay.
192
+ if (triage && onCall && onCall.currentName) {
193
+ const segs = [
194
+ { text: "◷ ", color: onCall.currentIsMe ? "cyan" : "green", bold: true },
195
+ { text: "on call: ", dim: true },
196
+ {
197
+ text: onCall.currentName,
198
+ color: onCall.currentIsMe ? "cyan" : "green",
199
+ bold: true,
200
+ },
201
+ ];
202
+ if (onCall.myNext) {
203
+ segs.push({ text: " · you're up ", dim: true });
204
+ segs.push({ text: onCall.myNext, color: "cyan" });
205
+ }
206
+ segs.push({ text: " · [s] schedule", dim: true });
207
+ lines.push({ text: "", segments: segs });
208
+ lines.push(ruleLine);
209
+ }
160
210
  // ── Hero: identifier + title, then a status pill row ───────────────
161
211
  lines.push({ text: `${li.identifier} ${li.title}`, bold: true });
162
212
  const sc = stateColor(li.state.type);
163
- const heroSegs = [
164
- { text: "● ", color: sc },
165
- { text: li.state.name, color: sc },
166
- { text: " · ", dim: true },
167
- { text: li.priorityLabel },
168
- ];
213
+ const heroSegs = [];
214
+ // On the Triage tab every issue is in the "Triage" state by definition, so the
215
+ // status pill is noise — lead with priority instead.
216
+ if (!triage) {
217
+ heroSegs.push({ text: "● ", color: sc }, { text: li.state.name, color: sc }, { text: " · ", dim: true });
218
+ }
219
+ heroSegs.push({ text: li.priorityLabel });
169
220
  if (li.labels.length > 0) {
170
221
  heroSegs.push({ text: " · ", dim: true });
171
222
  heroSegs.push({ text: li.labels.join(", "), dim: true });
172
223
  }
173
224
  lines.push({ text: "", segments: heroSegs });
225
+ // ── SLA countdown ─────────────────────────────────────────────────
226
+ // Urgency-coded time-to-breach; shown whenever the issue carries an SLA
227
+ // (Triage tab). Snoozed issues are parked, so render greyed with a marker.
228
+ const sla = formatSla(li.slaBreachesAt);
229
+ const snoozed = isSnoozed(li.snoozedUntilAt);
230
+ if (sla) {
231
+ const col = snoozed ? "gray" : sla.color;
232
+ const bold = !snoozed && sla.urgent;
233
+ lines.push({
234
+ text: "",
235
+ segments: [
236
+ { text: "◷ ", color: col, bold },
237
+ { text: sla.label, color: col, bold },
238
+ ...(snoozed ? [{ text: " · snoozed", color: "gray" }] : []),
239
+ ],
240
+ });
241
+ }
242
+ else if (snoozed) {
243
+ lines.push({ text: "", segments: [{ text: "◷ snoozed", color: "gray" }] });
244
+ }
174
245
  // ── Description ───────────────────────────────────────────────────
175
246
  if (li.description) {
176
247
  lines.push({ text: "" });
@@ -178,6 +249,81 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
178
249
  lines.push({ text: dLine });
179
250
  }
180
251
  }
252
+ // ── Dependencies ──────────────────────────────────────────────────
253
+ // Blocking relations from the tracker. Header carries a readiness badge so
254
+ // the user can tell at a glance whether the issue is startable.
255
+ const blockedBy = li.blockedBy ?? [];
256
+ const blocking = li.blocking ?? [];
257
+ if (!triage && (blockedBy.length > 0 || blocking.length > 0)) {
258
+ const readiness = issueReadiness(li.blockedBy);
259
+ lines.push(ruleLine);
260
+ const headerSegs = [
261
+ { text: "⇄ ", color: "cyan", bold: true },
262
+ { text: "Dependencies", bold: true },
263
+ { text: " " },
264
+ readiness === "ready"
265
+ ? { text: "✓ ready to start", color: "green", bold: true }
266
+ : { text: "⊘ blocked", color: "yellow", bold: true },
267
+ ];
268
+ lines.push({ text: "", segments: headerSegs });
269
+ if (blockedBy.length > 0) {
270
+ lines.push({ text: " blocked by", dim: true });
271
+ for (const b of blockedBy) {
272
+ lines.push({
273
+ text: "",
274
+ segments: [
275
+ { text: ` ${b.done ? "✓" : "○"} `, color: b.done ? "green" : "yellow" },
276
+ { text: b.identifier, color: b.done ? undefined : "yellow", dim: b.done },
277
+ ...(b.done ? [{ text: " done", dim: true }] : []),
278
+ ],
279
+ });
280
+ }
281
+ }
282
+ if (blocking.length > 0) {
283
+ lines.push({ text: " blocks", dim: true });
284
+ lines.push({
285
+ text: "",
286
+ segments: [{ text: " " }, { text: blocking.map((b) => b.identifier).join(", ") }],
287
+ });
288
+ }
289
+ }
290
+ // ── Triage: discussion only ───────────────────────────────────────
291
+ // Triage issues live in the inbox — no worktree/PR/checks apply yet. Show
292
+ // the comment thread (lazily fetched by the dashboard) so the user can read
293
+ // the back-and-forth before deciding to ask a question or send it to a tree.
294
+ if (triage) {
295
+ lines.push(ruleLine);
296
+ lines.push(sectionHeader("≡", "Comments", "cyan"));
297
+ if (comments === undefined) {
298
+ lines.push({ text: " loading…", dim: true });
299
+ }
300
+ else if (comments.length === 0) {
301
+ lines.push({ text: " no comments", dim: true });
302
+ }
303
+ else {
304
+ const renderComment = (c, depth) => {
305
+ const indent = " ".repeat(depth + 1);
306
+ lines.push({
307
+ text: "",
308
+ segments: [
309
+ { text: indent },
310
+ { text: c.author, color: "cyan", bold: true },
311
+ { text: ` ${new Date(c.createdAt).toLocaleDateString()}`, dim: true },
312
+ ],
313
+ });
314
+ for (const bodyLine of c.body.trimEnd().split("\n")) {
315
+ lines.push({ text: `${indent}${bodyLine}` });
316
+ }
317
+ lines.push({ text: "" });
318
+ for (const child of c.children)
319
+ renderComment(child, depth + 1);
320
+ };
321
+ for (const c of comments)
322
+ renderComment(c, 0);
323
+ }
324
+ // Skip the worktree/PR/checks/reviews sections entirely for triage.
325
+ return renderLines(lines, scrollOffset, height, width);
326
+ }
181
327
  // ── Worktree ──────────────────────────────────────────────────────
182
328
  lines.push(ruleLine);
183
329
  if (worktree) {
@@ -468,6 +614,11 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
468
614
  // Action footer is rendered by the dashboard one row outside the panel,
469
615
  // alongside the global command bar, so left- and right-pane key hints sit
470
616
  // on the same row. The panel itself uses its full height for content.
617
+ return renderLines(lines, scrollOffset, height, width);
618
+ }
619
+ /** Scroll-clamp a built line list and render it into the panel box. Shared by
620
+ * the full detail view and the triage (discussion-only) view. */
621
+ function renderLines(lines, scrollOffset, height, width) {
471
622
  const totalLines = lines.length;
472
623
  const canScroll = totalLines > height;
473
624
  const contentRows = canScroll ? height - 2 : height;
@@ -8,6 +8,14 @@ interface Props {
8
8
  width: number;
9
9
  /** Theme-adapted selection background (light/dark). Falls back to dark navy. */
10
10
  selectionBg?: string;
11
+ /** Right-column variant (row structure — and click→row mapping — is identical):
12
+ * "default" — WT + CI status columns (Trees tab)
13
+ * "triage" — a colored SLA-countdown badge
14
+ * "issues" — a readiness glyph (ready / blocked by dependencies) */
15
+ variant?: "default" | "triage" | "issues";
16
+ /** Ticket ids whose worktree is currently being removed — shown with a
17
+ * distinct WT-column glyph so concurrent deletions are visible in the list. */
18
+ deletingIds?: Set<string>;
11
19
  }
12
20
  export type ListRow = {
13
21
  kind: "spacer";
@@ -28,5 +36,5 @@ export type ListRow = {
28
36
  depth: number;
29
37
  };
30
38
  export declare function buildIssueListRows(groups: ProjectGroup[], flatIssues: DashboardIssue[]): ListRow[];
31
- export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg, }: Props): import("react/jsx-runtime").JSX.Element;
39
+ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg, variant, deletingIds, }: Props): import("react/jsx-runtime").JSX.Element;
32
40
  export {};
@@ -1,5 +1,7 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import { formatSla, isSnoozed } from "./sla.js";
4
+ import { issueReadiness } from "../trackers/types.js";
3
5
  function stateColor(type, name) {
4
6
  const n = name?.toLowerCase();
5
7
  if (n === "blocked")
@@ -71,7 +73,13 @@ export function buildIssueListRows(groups, flatIssues) {
71
73
  rows.push({ kind: "spacer" });
72
74
  rows.push({ kind: "header", name: group.name, count: totalIssues, isFirst: gi === 0 });
73
75
  for (const sg of group.statusGroups) {
74
- rows.push({ kind: "status-header", name: sg.name, type: sg.type, count: sg.issues.length });
76
+ // A blank status name suppresses the per-status sub-header (used by the
77
+ // Triage tab, where the single group needs no "Triage" header). Both the
78
+ // renderer and the click→row mapper call this builder, so skipping a row
79
+ // here keeps their indices aligned automatically.
80
+ if (sg.name) {
81
+ rows.push({ kind: "status-header", name: sg.name, type: sg.type, count: sg.issues.length });
82
+ }
75
83
  for (const di of sg.issues) {
76
84
  pushIssueWithChildren(di, 0);
77
85
  }
@@ -85,11 +93,29 @@ export function buildIssueListRows(groups, flatIssues) {
85
93
  // Glyphs are 1 char and rendered right-aligned within their column.
86
94
  const LEFT_FIXED = 1 + 1 + 1 + 2 + 11; // 16 — left-aligned columns
87
95
  const RIGHT_FIXED = 2 + 2 + 2; // 6 — WT + 2 spaces + CI
96
+ // Triage variant: a single right-aligned SLA-countdown column. Widest badge is
97
+ // "breached" (8 chars); pad/clamp everything to this so titles align.
98
+ const SLA_COL_WIDTH = 8;
99
+ // Issues variant: a single readiness glyph under a "RDY" header.
100
+ const READY_COL_WIDTH = 3;
88
101
  const TITLE_GAP = 2; // minimum spacing between title and the right columns
89
- export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg = "#1e3a5f", }) {
102
+ function readinessGlyph(di) {
103
+ switch (issueReadiness(di.issue.blockedBy)) {
104
+ case "ready":
105
+ return { glyph: "✓", color: "green" };
106
+ case "blocked":
107
+ return { glyph: "⊘", color: "yellow" };
108
+ default:
109
+ return { glyph: "·", color: "gray" };
110
+ }
111
+ }
112
+ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg = "#1e3a5f", variant = "default", deletingIds, }) {
113
+ const isTriage = variant === "triage";
114
+ const isIssues = variant === "issues";
115
+ const rightFixed = isTriage ? SLA_COL_WIDTH : isIssues ? READY_COL_WIDTH : RIGHT_FIXED;
90
116
  const rows = buildIssueListRows(groups, flatIssues);
91
117
  const visible = rows.slice(scrollOffset, scrollOffset + height);
92
- const titleMaxWidth = Math.max(width - LEFT_FIXED - 1 /* leading space */ - RIGHT_FIXED - TITLE_GAP, 10);
118
+ const titleMaxWidth = Math.max(width - LEFT_FIXED - 1 /* leading space */ - rightFixed - TITLE_GAP, 10);
93
119
  return (_jsx(Box, { flexDirection: "column", width: width, height: height, children: _jsx(Box, { flexDirection: "column", height: height, children: visible.map((row, i) => {
94
120
  if (row.kind === "spacer") {
95
121
  return _jsx(Box, { height: 1 }, `sp-${i}`);
@@ -101,7 +127,7 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
101
127
  // Label "WT CI" is 5 chars; the "W" lines up with the WT
102
128
  // glyph at column (width - RIGHT_FIXED + 1).
103
129
  const namePart = `${row.name} ${row.count}`;
104
- const labelText = "WT CI";
130
+ const labelText = isTriage ? "SLA" : isIssues ? "RDY" : "WT CI";
105
131
  const labelPad = row.isFirst
106
132
  ? Math.max(2, width - namePart.length - labelText.length)
107
133
  : 0;
@@ -113,9 +139,13 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
113
139
  const { issue, flatIndex, depth } = row;
114
140
  const selected = flatIndex === selectedIndex;
115
141
  const di = issue;
116
- const sc = stateColor(di.issue.state.type, di.issue.state.name);
117
- const prio = priorityMarker(di.issue.priority);
118
- const work = workIndicator(di.worktree);
142
+ // Snoozed triage rows are parked work: grey them out (dot + id +
143
+ // title + SLA) so the active items the user should pick up stand out.
144
+ const snoozed = isTriage && isSnoozed(di.issue.snoozedUntilAt);
145
+ const sc = snoozed ? "gray" : stateColor(di.issue.state.type, di.issue.state.name);
146
+ const prio = snoozed ? { glyph: " ", color: "gray" } : priorityMarker(di.issue.priority);
147
+ const isDeleting = deletingIds?.has(di.issue.identifier) ?? false;
148
+ const work = isDeleting ? { glyph: "⌫", color: "yellow" } : workIndicator(di.worktree);
119
149
  const ci = ciIndicator(di.checks);
120
150
  const nestPrefix = depth > 0 ? " ".repeat(depth - 1) + "└ " : "";
121
151
  const adjustedTitleWidth = Math.max(titleMaxWidth - nestPrefix.length, 5);
@@ -123,9 +153,16 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
123
153
  ? di.issue.title.slice(0, adjustedTitleWidth - 1) + "…"
124
154
  : di.issue.title;
125
155
  const bg = selected ? selectionBg : undefined;
126
- // Pad between title and the right columns so the W/CI markers stay
156
+ // Pad between title and the right columns so the markers stay
127
157
  // pinned to the right edge regardless of title length.
128
- const trailingPad = Math.max(0, width - LEFT_FIXED - 1 - nestPrefix.length - title.length - RIGHT_FIXED);
129
- return (_jsxs(Box, { width: width, children: [_jsx(Text, { backgroundColor: bg, color: prio.color, children: prio.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, dimColor: true, children: [nestPrefix, di.issue.identifier.padEnd(10)] }), _jsxs(Text, { backgroundColor: bg, bold: selected, children: [" ", title] }), _jsx(Text, { backgroundColor: bg, children: " ".repeat(trailingPad) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: work.color, children: work.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: ci.color, children: ci.glyph })] }, di.issue.identifier));
158
+ const trailingPad = Math.max(0, width - LEFT_FIXED - 1 - nestPrefix.length - title.length - rightFixed);
159
+ // Triage variant: a single right-aligned SLA-countdown badge in
160
+ // place of the WT/CI columns. Snoozed rows show it greyed.
161
+ const sla = isTriage ? formatSla(di.issue.slaBreachesAt) : null;
162
+ const slaColor = snoozed ? "gray" : sla?.color;
163
+ const slaText = (sla?.label ?? "").padStart(SLA_COL_WIDTH).slice(-SLA_COL_WIDTH);
164
+ // Issues variant: a single readiness glyph right-aligned under "RDY".
165
+ const ready = isIssues ? readinessGlyph(di) : null;
166
+ return (_jsxs(Box, { width: width, children: [_jsx(Text, { backgroundColor: bg, color: prio.color, children: prio.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, dimColor: true, children: [nestPrefix, di.issue.identifier.padEnd(10)] }), _jsxs(Text, { backgroundColor: bg, bold: selected, color: snoozed ? "gray" : undefined, children: [" ", title] }), _jsx(Text, { backgroundColor: bg, children: " ".repeat(trailingPad) }), isTriage ? (_jsx(Text, { backgroundColor: bg, color: slaColor, bold: !snoozed && sla?.urgent, children: slaText })) : isIssues ? (_jsx(Text, { backgroundColor: bg, color: ready?.color, children: ` ${ready?.glyph ?? " "}` })) : (_jsxs(_Fragment, { children: [_jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: work.color, children: work.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: ci.color, children: ci.glyph })] }))] }, di.issue.identifier));
130
167
  }) }) }));
131
168
  }
@@ -30,15 +30,22 @@ const LEGEND = [
30
30
  {
31
31
  title: "Tabs & keys",
32
32
  rows: [
33
- { glyph: "1", color: "cyan", meaning: "Issues tab backlog / planning" },
34
- { glyph: "2", color: "cyan", meaning: "Trees tab — worktrees in progress" },
35
- { glyph: "3", color: "cyan", meaning: "Reviews tab — PRs awaiting your review" },
36
- { glyph: "Tab", color: "cyan", meaning: "Cycle Issues → Trees → Reviews" },
33
+ { glyph: "1–4", color: "cyan", meaning: "Switch tab by number (Tab cycles)" },
34
+ {
35
+ glyph: "Triage",
36
+ color: "cyan",
37
+ meaning: "Incoming inbox — SLA countdown + comments (Linear only)",
38
+ },
39
+ { glyph: "Issues", color: "cyan", meaning: "Backlog / planning" },
40
+ { glyph: "Trees", color: "cyan", meaning: "Worktrees in progress" },
41
+ { glyph: "Reviews", color: "cyan", meaning: "PRs awaiting your review" },
37
42
  { glyph: "t", color: "cyan", meaning: "Switch / set up the issue tracker" },
38
43
  { glyph: "n", color: "cyan", meaning: "New issue (built-in tracker, Issues tab)" },
39
44
  { glyph: "e", color: "cyan", meaning: "Edit issue (built-in tracker, Issues tab)" },
40
45
  { glyph: "d", color: "red", meaning: "Delete issue (built-in) / remove worktree (Trees)" },
41
- { glyph: "w", color: "cyan", meaning: "Start work (creates a worktree → Trees tab)" },
46
+ { glyph: "w", color: "cyan", meaning: "Start work / send to tree (creates a worktree)" },
47
+ { glyph: "a", color: "cyan", meaning: "Ask Claude about the issue + comments (Triage tab)" },
48
+ { glyph: "s", color: "cyan", meaning: "Triage on-call schedule (Triage tab)" },
42
49
  ],
43
50
  },
44
51
  {
@@ -56,6 +63,15 @@ const LEGEND = [
56
63
  { glyph: "✗", color: "red", meaning: "CI column: a check is failing" },
57
64
  { glyph: "●", color: "yellow", meaning: "CI column: checks pending / running" },
58
65
  { glyph: "·", color: "gray", meaning: "CI column: no PR or no checks" },
66
+ { glyph: "◷", color: "red", meaning: "SLA column (Triage): breached / under a day" },
67
+ { glyph: "◷", color: "yellow", meaning: "SLA column (Triage): under two days" },
68
+ { glyph: "●", color: "gray", meaning: "Triage row greyed: snoozed (parked, sunk to bottom)" },
69
+ { glyph: "✓", color: "green", meaning: "RDY column (Issues): ready — no open blockers" },
70
+ {
71
+ glyph: "⊘",
72
+ color: "yellow",
73
+ meaning: "RDY column (Issues): blocked by an open dependency",
74
+ },
59
75
  ],
60
76
  },
61
77
  {
@@ -0,0 +1,16 @@
1
+ import type { TriageSchedule } from "./types.js";
2
+ type Seg = {
3
+ text: string;
4
+ color?: string;
5
+ bold?: boolean;
6
+ dim?: boolean;
7
+ };
8
+ /** Flatten the schedules into colored segment-lines. Exported for testing. */
9
+ export declare function buildScheduleLines(schedules: TriageSchedule[]): Seg[][];
10
+ export default function TriageScheduleOverlay({ schedules, scrollOffset, width, height, }: {
11
+ schedules: TriageSchedule[];
12
+ scrollOffset: number;
13
+ width: number;
14
+ height: number;
15
+ }): import("react/jsx-runtime").JSX.Element;
16
+ export {};
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ function fmt(iso) {
4
+ const d = new Date(iso);
5
+ if (Number.isNaN(d.getTime()))
6
+ return iso;
7
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
8
+ }
9
+ /** Flatten the schedules into colored segment-lines. Exported for testing. */
10
+ export function buildScheduleLines(schedules) {
11
+ const lines = [];
12
+ if (schedules.length === 0) {
13
+ lines.push([{ text: "No triage on-call schedule found for your teams.", dim: true }]);
14
+ return lines;
15
+ }
16
+ schedules.forEach((sch, si) => {
17
+ if (si > 0)
18
+ lines.push([{ text: "" }]);
19
+ lines.push([
20
+ { text: "≡ ", color: "cyan", bold: true },
21
+ { text: sch.scheduleName, bold: true },
22
+ { text: ` ${sch.teamName}`, dim: true },
23
+ ]);
24
+ if (sch.currentName) {
25
+ lines.push([
26
+ { text: " on call now: ", dim: true },
27
+ { text: sch.currentName, color: sch.currentIsMe ? "cyan" : "green", bold: true },
28
+ ...(sch.currentIsMe ? [{ text: " ← you", color: "cyan" }] : []),
29
+ ]);
30
+ }
31
+ for (const sh of sch.shifts) {
32
+ const range = `${fmt(sh.startsAt)} – ${fmt(sh.endsAt)}`.padEnd(18);
33
+ const segs = [
34
+ { text: ` ${sh.isCurrent ? "●" : "·"} `, color: sh.isCurrent ? "green" : "gray" },
35
+ { text: range, dim: !sh.isCurrent && !sh.isMe },
36
+ {
37
+ text: sh.name,
38
+ color: sh.isCurrent ? "green" : sh.isMe ? "cyan" : undefined,
39
+ bold: sh.isCurrent || sh.isMe,
40
+ },
41
+ ];
42
+ if (sh.isCurrent)
43
+ segs.push({ text: " now", color: "green", bold: true });
44
+ else if (sh.isMe)
45
+ segs.push({ text: " you", color: "cyan" });
46
+ lines.push(segs);
47
+ }
48
+ });
49
+ return lines;
50
+ }
51
+ export default function TriageScheduleOverlay({ schedules, scrollOffset, width, height, }) {
52
+ const all = buildScheduleLines(schedules);
53
+ // Title (1) + blank (1) leave the rest for the body. The footer/close hint is
54
+ // owned by the dashboard's global command-bar row, so the panel stays pure
55
+ // content and lines up with the left pane.
56
+ const bodyHeight = Math.max(1, height - 2);
57
+ const maxOffset = Math.max(0, all.length - bodyHeight);
58
+ const off = Math.min(scrollOffset, maxOffset);
59
+ const visible = all.slice(off, off + bodyHeight);
60
+ const clampSegs = (segs) => {
61
+ let remaining = width - 1;
62
+ const out = [];
63
+ for (const s of segs) {
64
+ if (remaining <= 0)
65
+ break;
66
+ if (s.text.length <= remaining) {
67
+ out.push(s);
68
+ remaining -= s.text.length;
69
+ }
70
+ else {
71
+ out.push({ ...s, text: s.text.slice(0, Math.max(0, remaining - 1)) + "…" });
72
+ remaining = 0;
73
+ }
74
+ }
75
+ return out;
76
+ };
77
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Triage on-call schedule" }), _jsx(Text, { children: " " }), visible.map((segs, i) => (_jsx(Box, { children: _jsx(Text, { children: clampSegs(segs).map((s, j) => (_jsx(Text, { color: s.color, bold: s.bold, dimColor: s.dim, children: s.text || " " }, j))) }) }, i))), off + bodyHeight < all.length ? _jsx(Text, { dimColor: true, children: "\u2193 more (press s to scroll)" }) : null] }));
78
+ }
@@ -4,6 +4,8 @@ export declare function loadDashboardData(repoRoot: string): Promise<{
4
4
  flatIssues: DashboardIssue[];
5
5
  treeGroups: ProjectGroup[];
6
6
  flatTrees: DashboardIssue[];
7
+ triageGroups: ProjectGroup[];
8
+ flatTriage: DashboardIssue[];
7
9
  }>;
8
10
  export declare function loadReviewsData(repoRoot: string): Promise<{
9
11
  flatReviews: EnrichedReviewPR[];
@@ -3,6 +3,7 @@ import { runAsync } from "../exec.js";
3
3
  import { readMainAgentTodos, findClaudeSessionCwd } from "../claude-todos.js";
4
4
  import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, getGitHubUserNameAsync, } from "../github.js";
5
5
  import { getIssueTracker, getCandidateTrackers } from "../trackers/index.js";
6
+ import { isSnoozed } from "./sla.js";
6
7
  export async function loadDashboardData(repoRoot) {
7
8
  // Fetch issues and worktrees in parallel
8
9
  const tracker = getIssueTracker(repoRoot);
@@ -254,15 +255,49 @@ export async function loadDashboardData(repoRoot) {
254
255
  function flatten(g) {
255
256
  return g.flatMap((grp) => grp.statusGroups.flatMap((sg) => sg.issues.flatMap(flattenWithChildren)));
256
257
  }
257
- // ── Partition: Issues tab (backlog/planning) vs Trees tab (work in
258
- // progress). A tracker issue with no worktree is backlog; once it gains a
258
+ // ── Partition: Triage / Issues (backlog) / Trees (work in progress).
259
+ // A tracker issue with no worktree is backlog unless the active tracker
260
+ // has a triage inbox and the issue sits in it (state.type === "triage"), in
261
+ // which case it goes to the Triage tab instead. Once any issue gains a
259
262
  // worktree it moves to the Trees tab. Children always have a worktree, so
260
263
  // they only ever appear nested in Trees. Main-repo + orphaned worktrees
261
264
  // belong to Trees (they're active checkouts, not backlog).
262
- const backlogIssues = enriched.filter((di) => !di.worktree);
265
+ const triageEnabled = tracker.supportsTriage === true;
266
+ const isTriage = (di) => triageEnabled && !di.worktree && di.issue.state.type === "triage";
267
+ const triageIssues = enriched.filter(isTriage);
268
+ const backlogIssues = enriched.filter((di) => !di.worktree && !isTriage(di));
263
269
  const treeIssues = enriched.filter((di) => di.worktree);
270
+ // Order the triage inbox so the work that needs attention now is on top:
271
+ // active (non-snoozed) items first, snoozed parked at the bottom; within
272
+ // each, by SLA breach time ascending (breached/soonest first), SLA-less last.
273
+ const slaRank = (di) => {
274
+ const s = di.issue.slaBreachesAt;
275
+ const t = s ? Date.parse(s) : NaN;
276
+ return Number.isNaN(t) ? Number.POSITIVE_INFINITY : t;
277
+ };
278
+ triageIssues.sort((a, b) => {
279
+ const sa = isSnoozed(a.issue.snoozedUntilAt) ? 1 : 0;
280
+ const sb = isSnoozed(b.issue.snoozedUntilAt) ? 1 : 0;
281
+ if (sa !== sb)
282
+ return sa - sb;
283
+ return slaRank(a) - slaRank(b);
284
+ });
264
285
  const groups = buildProjectGroups(backlogIssues);
265
286
  const flatIssues = flatten(groups);
287
+ // Triage is scoped to issues assigned to the viewer, so project grouping and
288
+ // the redundant "Triage" status header add noise. Render one flat group under
289
+ // a single "Assigned to me" header (its column label is the SLA badge). The
290
+ // empty status-group name makes IssueList skip the per-status sub-header.
291
+ const triageGroups = triageIssues.length
292
+ ? [
293
+ {
294
+ name: "Assigned to me",
295
+ id: null,
296
+ statusGroups: [{ name: "", type: "triage", issues: triageIssues }],
297
+ },
298
+ ]
299
+ : [];
300
+ const flatTriage = flatten(triageGroups);
266
301
  const treeGroups = buildProjectGroups(treeIssues);
267
302
  const topLevelOrphans = orphans.filter((di) => !childTicketIds.has(di.issue.identifier));
268
303
  if (topLevelOrphans.length > 0) {
@@ -286,7 +321,7 @@ export async function loadDashboardData(repoRoot) {
286
321
  });
287
322
  flatTrees.unshift(mainEntry);
288
323
  }
289
- return { groups, flatIssues, treeGroups, flatTrees };
324
+ return { groups, flatIssues, treeGroups, flatTrees, triageGroups, flatTriage };
290
325
  }
291
326
  /** Build the synthetic dashboard row for the main repo checkout — the
292
327
  * non-worktree clone that the user typically commits master/main from.