santree 0.7.2 → 0.7.4
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/commands/dashboard.js +52 -4
- package/dist/lib/dashboard/DetailPanel.d.ts +1 -0
- package/dist/lib/dashboard/DetailPanel.js +29 -14
- package/dist/lib/dashboard/IssueList.d.ts +1 -1
- package/dist/lib/dashboard/IssueList.js +24 -14
- package/dist/lib/dashboard/Overlays.js +9 -3
- package/dist/lib/dashboard/data.js +27 -14
- package/dist/lib/dashboard/sla.d.ts +27 -0
- package/dist/lib/dashboard/sla.js +35 -0
- package/dist/lib/trackers/linear/api.js +6 -3
- package/dist/lib/trackers/types.d.ts +10 -5
- package/dist/lib/triage-config.d.ts +21 -0
- package/dist/lib/triage-config.js +32 -0
- package/package.json +1 -1
- package/dist/lib/dashboard/due.d.ts +0 -24
- package/dist/lib/dashboard/due.js +0 -32
|
@@ -10,7 +10,8 @@ const require = createRequire(import.meta.url);
|
|
|
10
10
|
const { version } = require("../../package.json");
|
|
11
11
|
import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, getDiffTool, getWorktreeStatus, stageFile, unstageFile, stageAll, unstageAll, discardFile, } from "../lib/git.js";
|
|
12
12
|
import { run, spawnAsync } from "../lib/exec.js";
|
|
13
|
-
import { resolveAgentBinary, fillCommitMessage, askTicketQuestion } from "../lib/ai.js";
|
|
13
|
+
import { resolveAgentBinary, resolveClaudeBinary, fillCommitMessage, askTicketQuestion, } from "../lib/ai.js";
|
|
14
|
+
import { readTriageInvestigateConfig, isTriageInvestigateConfigured, buildInvestigatePrompt, buildInvestigateCommand, } from "../lib/triage-config.js";
|
|
14
15
|
import { getInstalledClaudeVersion } from "../lib/version.js";
|
|
15
16
|
import { extractTicketId, getStagedDiffContent } from "../lib/git.js";
|
|
16
17
|
import { getMultiplexer } from "../lib/multiplexer/index.js";
|
|
@@ -325,6 +326,11 @@ export default function Dashboard() {
|
|
|
325
326
|
// Triage tab appears at all. Recomputed on every data refresh — feature
|
|
326
327
|
// detection via `tracker.supportsTriage`, never a kind check.
|
|
327
328
|
const [supportsTriage, setSupportsTriage] = useState(false);
|
|
329
|
+
// Whether `.santree/metadata.json` configures an "investigate triage ticket"
|
|
330
|
+
// skill/prompt (`_triage.skill_name` / `_triage.prompt`). Drives whether the
|
|
331
|
+
// Triage `[i]` action renders enabled (cyan) or greyed. Read fresh on every
|
|
332
|
+
// data refresh so manual edits to metadata.json show up within a cycle.
|
|
333
|
+
const [triageInvestigateConfigured, setTriageInvestigateConfigured] = useState(false);
|
|
328
334
|
const refreshTimerRef = useRef(null);
|
|
329
335
|
const repoRootRef = useRef(null);
|
|
330
336
|
const stateRef = useRef(state);
|
|
@@ -440,6 +446,7 @@ export default function Dashboard() {
|
|
|
440
446
|
setHasWorkspaceFile(hasWs);
|
|
441
447
|
const tracker = getIssueTracker(repoRoot);
|
|
442
448
|
setSupportsTriage(tracker.supportsTriage === true);
|
|
449
|
+
setTriageInvestigateConfigured(isTriageInvestigateConfigured(readTriageInvestigateConfig(repoRoot)));
|
|
443
450
|
dispatch({ type: "SET_DATA", ...data });
|
|
444
451
|
dispatch({ type: "SET_REVIEWS_DATA", flatReviews: reviewData.flatReviews });
|
|
445
452
|
// Triage on-call rotations (Linear). Best-effort, non-blocking — never
|
|
@@ -2658,6 +2665,43 @@ export default function Dashboard() {
|
|
|
2658
2665
|
dispatch({ type: "TRIAGE_ASK_OPEN", ticketId: di.issue.identifier });
|
|
2659
2666
|
return;
|
|
2660
2667
|
}
|
|
2668
|
+
// Investigate — hand the ticket to a user-configured skill/prompt in a
|
|
2669
|
+
// new multiplexer window. Config lives in `.santree/metadata.json`
|
|
2670
|
+
// (`_triage.skill_name` or `_triage.prompt`); read fresh so manual
|
|
2671
|
+
// edits take effect without waiting for a refresh.
|
|
2672
|
+
if (input === "i") {
|
|
2673
|
+
const root = repoRootRef.current;
|
|
2674
|
+
if (!root)
|
|
2675
|
+
return;
|
|
2676
|
+
const prompt = buildInvestigatePrompt(readTriageInvestigateConfig(root), di.issue.identifier);
|
|
2677
|
+
if (!prompt) {
|
|
2678
|
+
dispatch({
|
|
2679
|
+
type: "SET_ACTION_MESSAGE",
|
|
2680
|
+
message: "Set _triage.skill_name (or _triage.prompt) in .santree/metadata.json to enable investigate",
|
|
2681
|
+
});
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
const mux = getMultiplexer();
|
|
2685
|
+
if (!mux.isActive()) {
|
|
2686
|
+
dispatch({
|
|
2687
|
+
type: "SET_ACTION_MESSAGE",
|
|
2688
|
+
message: "Investigate needs a multiplexer (tmux or cmux)",
|
|
2689
|
+
});
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
2692
|
+
const command = buildInvestigateCommand(resolveClaudeBinary() ?? "claude", prompt);
|
|
2693
|
+
const windowName = `investigate-${di.issue.identifier}`;
|
|
2694
|
+
void (async () => {
|
|
2695
|
+
const created = await mux.createWindow({ name: windowName, cwd: root, command });
|
|
2696
|
+
dispatch({
|
|
2697
|
+
type: "SET_ACTION_MESSAGE",
|
|
2698
|
+
message: created.ok
|
|
2699
|
+
? "Launched investigation in new window"
|
|
2700
|
+
: `Failed to launch investigation${created.message ? `: ${created.message}` : ""}`,
|
|
2701
|
+
});
|
|
2702
|
+
})();
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2661
2705
|
if (input === "o") {
|
|
2662
2706
|
if (!di.issue.url) {
|
|
2663
2707
|
dispatch({ type: "SET_ACTION_MESSAGE", message: "No issue URL available" });
|
|
@@ -3108,14 +3152,14 @@ export default function Dashboard() {
|
|
|
3108
3152
|
? state.treeDetailScrollOffset
|
|
3109
3153
|
: state.activeTab === "triage"
|
|
3110
3154
|
? state.triageDetailScrollOffset
|
|
3111
|
-
: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs, deleteStatus: selectedDeleteStatus, triage: state.activeTab === "triage", comments: triageComments, onCall: onCall })) })] })), _jsx(Box, { children: state.overlay === "diff" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "diff" }) })) : state.overlay === "triage-schedule" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " scroll" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " close" })] }) })) : (_jsxs(_Fragment, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay, trackerName: activeTracker.displayName, canMutate: activeTracker.canMutate === true }) })] })) })] })] }));
|
|
3155
|
+
: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs, deleteStatus: selectedDeleteStatus, triage: state.activeTab === "triage", comments: triageComments, onCall: onCall })) })] })), _jsx(Box, { children: state.overlay === "diff" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "diff" }) })) : state.overlay === "triage-schedule" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " scroll" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " close" })] }) })) : (_jsxs(_Fragment, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay, trackerName: activeTracker.displayName, canMutate: activeTracker.canMutate === true, triageInvestigateConfigured: triageInvestigateConfigured }) })] })) })] })] }));
|
|
3112
3156
|
}
|
|
3113
3157
|
/**
|
|
3114
3158
|
* Renders the per-issue action key hints (Resume / Editor / View diff / …)
|
|
3115
3159
|
* lifted out of the detail panels so they sit on the same row as the global
|
|
3116
3160
|
* command bar. Empty when nothing is selected.
|
|
3117
3161
|
*/
|
|
3118
|
-
function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, trackerName, canMutate, }) {
|
|
3162
|
+
function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, trackerName, canMutate, triageInvestigateConfigured, }) {
|
|
3119
3163
|
// During the diff overlay, none of the per-issue actions apply (View diff
|
|
3120
3164
|
// is what got us here, Commit/PR/etc. need the detail panel context). Keep
|
|
3121
3165
|
// the row blank so the diff-specific CommandBar reads cleanly.
|
|
@@ -3126,7 +3170,11 @@ function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, trackerN
|
|
|
3126
3170
|
? buildReviewActions(selectedReview)
|
|
3127
3171
|
: []
|
|
3128
3172
|
: selectedIssue
|
|
3129
|
-
? buildIssueActions(selectedIssue, trackerName, {
|
|
3173
|
+
? buildIssueActions(selectedIssue, trackerName, {
|
|
3174
|
+
tab: activeTab,
|
|
3175
|
+
canMutate,
|
|
3176
|
+
triageInvestigateConfigured,
|
|
3177
|
+
})
|
|
3130
3178
|
: [];
|
|
3131
3179
|
if (items.length === 0)
|
|
3132
3180
|
return _jsx(Text, { children: " " });
|
|
@@ -37,6 +37,7 @@ export type IssueActionItem = {
|
|
|
37
37
|
export declare function buildIssueActions(di: DashboardIssue, trackerName: string, opts?: {
|
|
38
38
|
tab?: DashboardTab;
|
|
39
39
|
canMutate?: boolean;
|
|
40
|
+
triageInvestigateConfigured?: boolean;
|
|
40
41
|
}): IssueActionItem[];
|
|
41
42
|
export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, deleteStatus, triage, comments, onCall, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
42
43
|
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
-
import {
|
|
3
|
+
import { formatSla, isSnoozed } from "./sla.js";
|
|
4
4
|
import { issueReadiness } from "../trackers/types.js";
|
|
5
5
|
function stateColor(type) {
|
|
6
6
|
switch (type) {
|
|
@@ -85,6 +85,13 @@ export function buildIssueActions(di, trackerName, opts) {
|
|
|
85
85
|
if (opts?.tab === "triage") {
|
|
86
86
|
items.push({ key: "w", label: "Send to tree", color: "cyan" });
|
|
87
87
|
items.push({ key: "a", label: "Ask", color: "cyan" });
|
|
88
|
+
// Greyed until `_triage.skill_name`/`_triage.prompt` is set in
|
|
89
|
+
// `.santree/metadata.json` — pressing it then just prints a hint.
|
|
90
|
+
items.push({
|
|
91
|
+
key: "i",
|
|
92
|
+
label: "Investigate",
|
|
93
|
+
color: opts.triageInvestigateConfigured ? "cyan" : "gray",
|
|
94
|
+
});
|
|
88
95
|
items.push({ key: "s", label: "Schedule", color: "cyan" });
|
|
89
96
|
if (issue.url) {
|
|
90
97
|
items.push({ key: "o", label: trackerName, color: "gray" });
|
|
@@ -210,30 +217,38 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
|
|
|
210
217
|
// ── Hero: identifier + title, then a status pill row ───────────────
|
|
211
218
|
lines.push({ text: `${li.identifier} ${li.title}`, bold: true });
|
|
212
219
|
const sc = stateColor(li.state.type);
|
|
213
|
-
const heroSegs = [
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
{ text: li.
|
|
218
|
-
|
|
220
|
+
const heroSegs = [];
|
|
221
|
+
// On the Triage tab every issue is in the "Triage" state by definition, so the
|
|
222
|
+
// status pill is noise — lead with priority instead.
|
|
223
|
+
if (!triage) {
|
|
224
|
+
heroSegs.push({ text: "● ", color: sc }, { text: li.state.name, color: sc }, { text: " · ", dim: true });
|
|
225
|
+
}
|
|
226
|
+
heroSegs.push({ text: li.priorityLabel });
|
|
219
227
|
if (li.labels.length > 0) {
|
|
220
228
|
heroSegs.push({ text: " · ", dim: true });
|
|
221
229
|
heroSegs.push({ text: li.labels.join(", "), dim: true });
|
|
222
230
|
}
|
|
223
231
|
lines.push({ text: "", segments: heroSegs });
|
|
224
|
-
// ──
|
|
225
|
-
// Urgency-coded; shown whenever the issue carries
|
|
226
|
-
// Triage tab,
|
|
227
|
-
const
|
|
228
|
-
|
|
232
|
+
// ── SLA countdown ─────────────────────────────────────────────────
|
|
233
|
+
// Urgency-coded time-to-breach; shown whenever the issue carries an SLA
|
|
234
|
+
// (Triage tab). Snoozed issues are parked, so render greyed with a marker.
|
|
235
|
+
const sla = formatSla(li.slaBreachesAt);
|
|
236
|
+
const snoozed = isSnoozed(li.snoozedUntilAt);
|
|
237
|
+
if (sla) {
|
|
238
|
+
const col = snoozed ? "gray" : sla.color;
|
|
239
|
+
const bold = !snoozed && sla.urgent;
|
|
229
240
|
lines.push({
|
|
230
241
|
text: "",
|
|
231
242
|
segments: [
|
|
232
|
-
{ text: "◷ ", color:
|
|
233
|
-
{ text:
|
|
243
|
+
{ text: "◷ ", color: col, bold },
|
|
244
|
+
{ text: sla.label, color: col, bold },
|
|
245
|
+
...(snoozed ? [{ text: " · snoozed", color: "gray" }] : []),
|
|
234
246
|
],
|
|
235
247
|
});
|
|
236
248
|
}
|
|
249
|
+
else if (snoozed) {
|
|
250
|
+
lines.push({ text: "", segments: [{ text: "◷ snoozed", color: "gray" }] });
|
|
251
|
+
}
|
|
237
252
|
// ── Description ───────────────────────────────────────────────────
|
|
238
253
|
if (li.description) {
|
|
239
254
|
lines.push({ text: "" });
|
|
@@ -10,7 +10,7 @@ interface Props {
|
|
|
10
10
|
selectionBg?: string;
|
|
11
11
|
/** Right-column variant (row structure — and click→row mapping — is identical):
|
|
12
12
|
* "default" — WT + CI status columns (Trees tab)
|
|
13
|
-
* "triage" — a colored
|
|
13
|
+
* "triage" — a colored SLA-countdown badge
|
|
14
14
|
* "issues" — a readiness glyph (ready / blocked by dependencies) */
|
|
15
15
|
variant?: "default" | "triage" | "issues";
|
|
16
16
|
/** Ticket ids whose worktree is currently being removed — shown with a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
-
import {
|
|
3
|
+
import { formatSla, isSnoozed } from "./sla.js";
|
|
4
4
|
import { issueReadiness } from "../trackers/types.js";
|
|
5
5
|
function stateColor(type, name) {
|
|
6
6
|
const n = name?.toLowerCase();
|
|
@@ -73,7 +73,13 @@ export function buildIssueListRows(groups, flatIssues) {
|
|
|
73
73
|
rows.push({ kind: "spacer" });
|
|
74
74
|
rows.push({ kind: "header", name: group.name, count: totalIssues, isFirst: gi === 0 });
|
|
75
75
|
for (const sg of group.statusGroups) {
|
|
76
|
-
|
|
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
|
+
}
|
|
77
83
|
for (const di of sg.issues) {
|
|
78
84
|
pushIssueWithChildren(di, 0);
|
|
79
85
|
}
|
|
@@ -87,9 +93,9 @@ export function buildIssueListRows(groups, flatIssues) {
|
|
|
87
93
|
// Glyphs are 1 char and rendered right-aligned within their column.
|
|
88
94
|
const LEFT_FIXED = 1 + 1 + 1 + 2 + 11; // 16 — left-aligned columns
|
|
89
95
|
const RIGHT_FIXED = 2 + 2 + 2; // 6 — WT + 2 spaces + CI
|
|
90
|
-
// Triage variant: a single right-aligned
|
|
91
|
-
// "
|
|
92
|
-
const
|
|
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;
|
|
93
99
|
// Issues variant: a single readiness glyph under a "RDY" header.
|
|
94
100
|
const READY_COL_WIDTH = 3;
|
|
95
101
|
const TITLE_GAP = 2; // minimum spacing between title and the right columns
|
|
@@ -106,7 +112,7 @@ function readinessGlyph(di) {
|
|
|
106
112
|
export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg = "#1e3a5f", variant = "default", deletingIds, }) {
|
|
107
113
|
const isTriage = variant === "triage";
|
|
108
114
|
const isIssues = variant === "issues";
|
|
109
|
-
const rightFixed = isTriage ?
|
|
115
|
+
const rightFixed = isTriage ? SLA_COL_WIDTH : isIssues ? READY_COL_WIDTH : RIGHT_FIXED;
|
|
110
116
|
const rows = buildIssueListRows(groups, flatIssues);
|
|
111
117
|
const visible = rows.slice(scrollOffset, scrollOffset + height);
|
|
112
118
|
const titleMaxWidth = Math.max(width - LEFT_FIXED - 1 /* leading space */ - rightFixed - TITLE_GAP, 10);
|
|
@@ -121,7 +127,7 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
|
|
|
121
127
|
// Label "WT CI" is 5 chars; the "W" lines up with the WT
|
|
122
128
|
// glyph at column (width - RIGHT_FIXED + 1).
|
|
123
129
|
const namePart = `${row.name} ${row.count}`;
|
|
124
|
-
const labelText = isTriage ? "
|
|
130
|
+
const labelText = isTriage ? "SLA" : isIssues ? "RDY" : "WT CI";
|
|
125
131
|
const labelPad = row.isFirst
|
|
126
132
|
? Math.max(2, width - namePart.length - labelText.length)
|
|
127
133
|
: 0;
|
|
@@ -133,8 +139,11 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
|
|
|
133
139
|
const { issue, flatIndex, depth } = row;
|
|
134
140
|
const selected = flatIndex === selectedIndex;
|
|
135
141
|
const di = issue;
|
|
136
|
-
|
|
137
|
-
|
|
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);
|
|
138
147
|
const isDeleting = deletingIds?.has(di.issue.identifier) ?? false;
|
|
139
148
|
const work = isDeleting ? { glyph: "⌫", color: "yellow" } : workIndicator(di.worktree);
|
|
140
149
|
const ci = ciIndicator(di.checks);
|
|
@@ -147,12 +156,13 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
|
|
|
147
156
|
// Pad between title and the right columns so the markers stay
|
|
148
157
|
// pinned to the right edge regardless of title length.
|
|
149
158
|
const trailingPad = Math.max(0, width - LEFT_FIXED - 1 - nestPrefix.length - title.length - rightFixed);
|
|
150
|
-
// Triage variant: a single right-aligned
|
|
151
|
-
// of the WT/CI columns.
|
|
152
|
-
const
|
|
153
|
-
const
|
|
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);
|
|
154
164
|
// Issues variant: a single readiness glyph right-aligned under "RDY".
|
|
155
165
|
const ready = isIssues ? readinessGlyph(di) : null;
|
|
156
|
-
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) }), isTriage ? (_jsx(Text, { backgroundColor: bg, color:
|
|
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));
|
|
157
167
|
}) }) }));
|
|
158
168
|
}
|
|
@@ -34,7 +34,7 @@ const LEGEND = [
|
|
|
34
34
|
{
|
|
35
35
|
glyph: "Triage",
|
|
36
36
|
color: "cyan",
|
|
37
|
-
meaning: "Incoming inbox —
|
|
37
|
+
meaning: "Incoming inbox — SLA countdown + comments (Linear only)",
|
|
38
38
|
},
|
|
39
39
|
{ glyph: "Issues", color: "cyan", meaning: "Backlog / planning" },
|
|
40
40
|
{ glyph: "Trees", color: "cyan", meaning: "Worktrees in progress" },
|
|
@@ -45,6 +45,11 @@ const LEGEND = [
|
|
|
45
45
|
{ glyph: "d", color: "red", meaning: "Delete issue (built-in) / remove worktree (Trees)" },
|
|
46
46
|
{ glyph: "w", color: "cyan", meaning: "Start work / send to tree (creates a worktree)" },
|
|
47
47
|
{ glyph: "a", color: "cyan", meaning: "Ask Claude about the issue + comments (Triage tab)" },
|
|
48
|
+
{
|
|
49
|
+
glyph: "i",
|
|
50
|
+
color: "cyan",
|
|
51
|
+
meaning: "Investigate ticket via your configured skill/prompt (Triage tab)",
|
|
52
|
+
},
|
|
48
53
|
{ glyph: "s", color: "cyan", meaning: "Triage on-call schedule (Triage tab)" },
|
|
49
54
|
],
|
|
50
55
|
},
|
|
@@ -63,8 +68,9 @@ const LEGEND = [
|
|
|
63
68
|
{ glyph: "✗", color: "red", meaning: "CI column: a check is failing" },
|
|
64
69
|
{ glyph: "●", color: "yellow", meaning: "CI column: checks pending / running" },
|
|
65
70
|
{ glyph: "·", color: "gray", meaning: "CI column: no PR or no checks" },
|
|
66
|
-
{ glyph: "◷", color: "red", meaning: "
|
|
67
|
-
{ glyph: "◷", color: "yellow", meaning: "
|
|
71
|
+
{ glyph: "◷", color: "red", meaning: "SLA column (Triage): breached / under a day" },
|
|
72
|
+
{ glyph: "◷", color: "yellow", meaning: "SLA column (Triage): under two days" },
|
|
73
|
+
{ glyph: "●", color: "gray", meaning: "Triage row greyed: snoozed (parked, sunk to bottom)" },
|
|
68
74
|
{ glyph: "✓", color: "green", meaning: "RDY column (Issues): ready — no open blockers" },
|
|
69
75
|
{
|
|
70
76
|
glyph: "⊘",
|
|
@@ -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);
|
|
@@ -266,24 +267,36 @@ export async function loadDashboardData(repoRoot) {
|
|
|
266
267
|
const triageIssues = enriched.filter(isTriage);
|
|
267
268
|
const backlogIssues = enriched.filter((di) => !di.worktree && !isTriage(di));
|
|
268
269
|
const treeIssues = enriched.filter((di) => di.worktree);
|
|
269
|
-
//
|
|
270
|
-
// (
|
|
271
|
-
//
|
|
272
|
-
|
|
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
|
+
};
|
|
273
278
|
triageIssues.sort((a, b) => {
|
|
274
|
-
const
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
277
|
-
return
|
|
278
|
-
|
|
279
|
-
return -1;
|
|
280
|
-
if (db)
|
|
281
|
-
return 1;
|
|
282
|
-
return 0;
|
|
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);
|
|
283
284
|
});
|
|
284
285
|
const groups = buildProjectGroups(backlogIssues);
|
|
285
286
|
const flatIssues = flatten(groups);
|
|
286
|
-
|
|
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
|
+
: [];
|
|
287
300
|
const flatTriage = flatten(triageGroups);
|
|
288
301
|
const treeGroups = buildProjectGroups(treeIssues);
|
|
289
302
|
const topLevelOrphans = orphans.filter((di) => !childTicketIds.has(di.issue.identifier));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Triage SLA + snooze formatting for the Triage tab. Linear issues in triage
|
|
3
|
+
* carry an `slaBreachesAt` ISO timestamp (when the response SLA is breached);
|
|
4
|
+
* we render a short, urgency-coded countdown badge — the same "2d 23h" / "13h"
|
|
5
|
+
* shape Linear itself shows — used by both the issue list (right column) and
|
|
6
|
+
* the detail panel.
|
|
7
|
+
*
|
|
8
|
+
* Urgency buckets (by time until breach):
|
|
9
|
+
* - breached (≤ 0) → red, "breached"
|
|
10
|
+
* - imminent (< 24h) → red, "5h" / "45m"
|
|
11
|
+
* - soon (< 48h) → yellow, "1d 6h"
|
|
12
|
+
* - later (≥ 48h) → gray, "3d 2h"
|
|
13
|
+
*
|
|
14
|
+
* Returns null when there is no SLA so callers can render nothing.
|
|
15
|
+
*/
|
|
16
|
+
export interface SlaInfo {
|
|
17
|
+
/** Short badge text, e.g. "2d 23h", "13h", or "breached". */
|
|
18
|
+
label: string;
|
|
19
|
+
/** Ink color name keyed to urgency. */
|
|
20
|
+
color: "red" | "yellow" | "gray";
|
|
21
|
+
/** True for breached / < 24h — the cases worth a bold warning. */
|
|
22
|
+
urgent: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare function formatSla(slaBreachesAt: string | null | undefined, now?: Date): SlaInfo | null;
|
|
25
|
+
/** True when the issue is snoozed past the current moment. A snoozed triage
|
|
26
|
+
* issue is parked — the UI greys it and sinks it below active work. */
|
|
27
|
+
export declare function isSnoozed(snoozedUntilAt: string | null | undefined, now?: Date): boolean;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function formatSla(slaBreachesAt, now = new Date()) {
|
|
2
|
+
if (!slaBreachesAt)
|
|
3
|
+
return null;
|
|
4
|
+
const breach = Date.parse(slaBreachesAt);
|
|
5
|
+
if (Number.isNaN(breach))
|
|
6
|
+
return null;
|
|
7
|
+
const ms = breach - now.getTime();
|
|
8
|
+
if (ms <= 0)
|
|
9
|
+
return { label: "breached", color: "red", urgent: true };
|
|
10
|
+
const totalMin = Math.floor(ms / 60_000);
|
|
11
|
+
const days = Math.floor(totalMin / 1440);
|
|
12
|
+
const hours = Math.floor((totalMin % 1440) / 60);
|
|
13
|
+
const mins = totalMin % 60;
|
|
14
|
+
let label;
|
|
15
|
+
if (days >= 1)
|
|
16
|
+
label = `${days}d ${hours}h`;
|
|
17
|
+
else if (hours >= 1)
|
|
18
|
+
label = `${hours}h`;
|
|
19
|
+
else
|
|
20
|
+
label = `${mins}m`;
|
|
21
|
+
const hoursLeft = ms / 3_600_000;
|
|
22
|
+
if (hoursLeft < 24)
|
|
23
|
+
return { label, color: "red", urgent: true };
|
|
24
|
+
if (hoursLeft < 48)
|
|
25
|
+
return { label, color: "yellow", urgent: false };
|
|
26
|
+
return { label, color: "gray", urgent: false };
|
|
27
|
+
}
|
|
28
|
+
/** True when the issue is snoozed past the current moment. A snoozed triage
|
|
29
|
+
* issue is parked — the UI greys it and sinks it below active work. */
|
|
30
|
+
export function isSnoozed(snoozedUntilAt, now = new Date()) {
|
|
31
|
+
if (!snoozedUntilAt)
|
|
32
|
+
return false;
|
|
33
|
+
const until = Date.parse(snoozedUntilAt);
|
|
34
|
+
return !Number.isNaN(until) && until > now.getTime();
|
|
35
|
+
}
|
|
@@ -37,7 +37,8 @@ query GetIssue($id: String!) {
|
|
|
37
37
|
title
|
|
38
38
|
description
|
|
39
39
|
url
|
|
40
|
-
|
|
40
|
+
slaBreachesAt
|
|
41
|
+
snoozedUntilAt
|
|
41
42
|
state { name type }
|
|
42
43
|
priority
|
|
43
44
|
labels { nodes { name } }
|
|
@@ -73,7 +74,8 @@ query AssignedIssues {
|
|
|
73
74
|
title
|
|
74
75
|
description
|
|
75
76
|
url
|
|
76
|
-
|
|
77
|
+
slaBreachesAt
|
|
78
|
+
snoozedUntilAt
|
|
77
79
|
priority
|
|
78
80
|
state { name type }
|
|
79
81
|
labels { nodes { name } }
|
|
@@ -97,7 +99,8 @@ function mapAssigned(issue) {
|
|
|
97
99
|
title: issue.title,
|
|
98
100
|
description: issue.description ?? null,
|
|
99
101
|
url: issue.url,
|
|
100
|
-
|
|
102
|
+
slaBreachesAt: issue.slaBreachesAt ?? null,
|
|
103
|
+
snoozedUntilAt: issue.snoozedUntilAt ?? null,
|
|
101
104
|
priority: issue.priority,
|
|
102
105
|
priorityLabel: PRIORITY_MAP[issue.priority] ?? "No priority",
|
|
103
106
|
state: {
|
|
@@ -38,11 +38,16 @@ export interface AssignedIssue {
|
|
|
38
38
|
blockedBy?: IssueRef[];
|
|
39
39
|
/** Issues this one blocks (downstream dependents). */
|
|
40
40
|
blocking?: IssueRef[];
|
|
41
|
-
/**
|
|
42
|
-
* Only trackers with a native
|
|
43
|
-
* others leave it undefined. Surfaced on the Triage tab as a
|
|
44
|
-
* urgency-coded badge. */
|
|
45
|
-
|
|
41
|
+
/** When the issue's triage SLA breaches, as an ISO timestamp, or null when
|
|
42
|
+
* no SLA applies. Only trackers with a native SLA concept populate it
|
|
43
|
+
* (Linear today); others leave it undefined. Surfaced on the Triage tab as a
|
|
44
|
+
* colored, urgency-coded countdown badge. */
|
|
45
|
+
slaBreachesAt?: string | null;
|
|
46
|
+
/** When the issue is snoozed until, as an ISO timestamp, or null when not
|
|
47
|
+
* snoozed. A snooze in the future means the issue is parked — surfaced on the
|
|
48
|
+
* Triage tab as a greyed, sunk-to-the-bottom row so active work stands out.
|
|
49
|
+
* Linear-only today. */
|
|
50
|
+
snoozedUntilAt?: string | null;
|
|
46
51
|
}
|
|
47
52
|
export interface Issue extends AssignedIssue {
|
|
48
53
|
comments: Comment[];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Per-repo "investigate triage ticket" config, read from
|
|
2
|
+
* `.santree/metadata.json` (`_triage.skill_name` / `_triage.prompt`).
|
|
3
|
+
* metadata.json is gitignored, so this is per-machine — each dev points it at
|
|
4
|
+
* their own skill or prompt without committing it. */
|
|
5
|
+
export interface TriageInvestigateConfig {
|
|
6
|
+
/** A Claude slash-command/skill name. Invoked as `/<skill_name> <ticketId>`. */
|
|
7
|
+
skillName: string | null;
|
|
8
|
+
/** A free-form prompt template; `{ticket_id}` is substituted with the ticket. */
|
|
9
|
+
prompt: string | null;
|
|
10
|
+
}
|
|
11
|
+
export declare function readTriageInvestigateConfig(repoRoot: string): TriageInvestigateConfig;
|
|
12
|
+
export declare function isTriageInvestigateConfigured(cfg: TriageInvestigateConfig): boolean;
|
|
13
|
+
/** Builds the prompt handed to Claude for investigating a ticket. Skill name
|
|
14
|
+
* wins when both are set: `/<skill> <ticketId>`. Otherwise the free-form
|
|
15
|
+
* template with every `{ticket_id}` replaced. Returns null when unconfigured. */
|
|
16
|
+
export declare function buildInvestigatePrompt(cfg: TriageInvestigateConfig, ticketId: string): string | null;
|
|
17
|
+
/** Builds the shell command line launched in the new multiplexer window:
|
|
18
|
+
* `<claudeBin> '<prompt>'`. The multiplexer escapes the whole line again for
|
|
19
|
+
* its send-keys step; this layer just needs the prompt to survive as a single
|
|
20
|
+
* argv entry so a slash command (`/skill TEAM-1`) reaches Claude intact. */
|
|
21
|
+
export declare function buildInvestigateCommand(claudeBin: string, prompt: string): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readAllMetadata } from "./metadata.js";
|
|
2
|
+
export function readTriageInvestigateConfig(repoRoot) {
|
|
3
|
+
const all = readAllMetadata(repoRoot);
|
|
4
|
+
const t = all["_triage"];
|
|
5
|
+
const skillName = typeof t?.skill_name === "string" && t.skill_name.trim() ? t.skill_name.trim() : null;
|
|
6
|
+
const prompt = typeof t?.prompt === "string" && t.prompt.trim() ? t.prompt.trim() : null;
|
|
7
|
+
return { skillName, prompt };
|
|
8
|
+
}
|
|
9
|
+
export function isTriageInvestigateConfigured(cfg) {
|
|
10
|
+
return cfg.skillName !== null || cfg.prompt !== null;
|
|
11
|
+
}
|
|
12
|
+
/** Builds the prompt handed to Claude for investigating a ticket. Skill name
|
|
13
|
+
* wins when both are set: `/<skill> <ticketId>`. Otherwise the free-form
|
|
14
|
+
* template with every `{ticket_id}` replaced. Returns null when unconfigured. */
|
|
15
|
+
export function buildInvestigatePrompt(cfg, ticketId) {
|
|
16
|
+
if (cfg.skillName)
|
|
17
|
+
return `/${cfg.skillName} ${ticketId}`;
|
|
18
|
+
if (cfg.prompt)
|
|
19
|
+
return cfg.prompt.replace(/\{ticket_id\}/g, ticketId);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
/** POSIX single-quote a shell argument. */
|
|
23
|
+
function shellSingleQuote(s) {
|
|
24
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
25
|
+
}
|
|
26
|
+
/** Builds the shell command line launched in the new multiplexer window:
|
|
27
|
+
* `<claudeBin> '<prompt>'`. The multiplexer escapes the whole line again for
|
|
28
|
+
* its send-keys step; this layer just needs the prompt to survive as a single
|
|
29
|
+
* argv entry so a slash command (`/skill TEAM-1`) reaches Claude intact. */
|
|
30
|
+
export function buildInvestigateCommand(claudeBin, prompt) {
|
|
31
|
+
return `${claudeBin} ${shellSingleQuote(prompt)}`;
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Due-date formatting for the Triage tab. Turns Linear's `YYYY-MM-DD`
|
|
3
|
-
* `dueDate` string into a short, urgency-coded badge used by both the issue
|
|
4
|
-
* list (right column) and the detail panel.
|
|
5
|
-
*
|
|
6
|
-
* Urgency buckets (by whole calendar days from today, local time):
|
|
7
|
-
* - overdue (< 0) → red, "⚠ overdue Nd"
|
|
8
|
-
* - due today (0) → red, "⚠ due today"
|
|
9
|
-
* - soon (1–2 days) → yellow, "due in Nd"
|
|
10
|
-
* - later (≥ 3 days) → gray, "due Mon D"
|
|
11
|
-
*
|
|
12
|
-
* Returns null when there is no due date so callers can render nothing.
|
|
13
|
-
*/
|
|
14
|
-
export interface DueInfo {
|
|
15
|
-
/** Short badge text, e.g. "⚠ overdue 3d" or "due Jun 12". */
|
|
16
|
-
label: string;
|
|
17
|
-
/** Ink color name keyed to urgency. */
|
|
18
|
-
color: "red" | "yellow" | "gray";
|
|
19
|
-
/** True for overdue/today — the cases worth a warning glyph. */
|
|
20
|
-
urgent: boolean;
|
|
21
|
-
/** Whole days until due (negative when overdue). */
|
|
22
|
-
days: number;
|
|
23
|
-
}
|
|
24
|
-
export declare function formatDueDate(dueDate: string | null | undefined, now?: Date): DueInfo | null;
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/** Whole-day difference between two dates, ignoring time-of-day. */
|
|
2
|
-
function dayDiff(from, to) {
|
|
3
|
-
const a = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate());
|
|
4
|
-
const b = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate());
|
|
5
|
-
return Math.round((b - a) / 86_400_000);
|
|
6
|
-
}
|
|
7
|
-
export function formatDueDate(dueDate, now = new Date()) {
|
|
8
|
-
if (!dueDate)
|
|
9
|
-
return null;
|
|
10
|
-
// Parse `YYYY-MM-DD` as a local date (not UTC) so "today" matches the user's
|
|
11
|
-
// wall clock. `new Date("2026-06-12")` would parse as UTC midnight; build the
|
|
12
|
-
// date from parts instead.
|
|
13
|
-
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(dueDate);
|
|
14
|
-
if (!m)
|
|
15
|
-
return null;
|
|
16
|
-
const due = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
17
|
-
if (Number.isNaN(due.getTime()))
|
|
18
|
-
return null;
|
|
19
|
-
const days = dayDiff(now, due);
|
|
20
|
-
const monthDay = due.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
21
|
-
if (days < 0) {
|
|
22
|
-
const n = Math.abs(days);
|
|
23
|
-
return { label: `⚠ overdue ${n}d`, color: "red", urgent: true, days };
|
|
24
|
-
}
|
|
25
|
-
if (days === 0) {
|
|
26
|
-
return { label: "⚠ due today", color: "red", urgent: true, days };
|
|
27
|
-
}
|
|
28
|
-
if (days <= 2) {
|
|
29
|
-
return { label: `due in ${days}d`, color: "yellow", urgent: false, days };
|
|
30
|
-
}
|
|
31
|
-
return { label: `due ${monthDay}`, color: "gray", urgent: false, days };
|
|
32
|
-
}
|