santree 0.6.3 → 0.7.2
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 +463 -78
- package/dist/lib/ai.d.ts +13 -0
- package/dist/lib/ai.js +16 -0
- package/dist/lib/dashboard/DetailPanel.d.ts +18 -2
- package/dist/lib/dashboard/DetailPanel.js +150 -3
- package/dist/lib/dashboard/IssueList.d.ts +11 -1
- package/dist/lib/dashboard/IssueList.js +44 -9
- package/dist/lib/dashboard/Overlays.d.ts +2 -1
- package/dist/lib/dashboard/Overlays.js +23 -8
- package/dist/lib/dashboard/TriageScheduleOverlay.d.ts +16 -0
- package/dist/lib/dashboard/TriageScheduleOverlay.js +78 -0
- package/dist/lib/dashboard/data.d.ts +2 -0
- package/dist/lib/dashboard/data.js +26 -4
- package/dist/lib/dashboard/due.d.ts +24 -0
- package/dist/lib/dashboard/due.js +32 -0
- package/dist/lib/dashboard/types.d.ts +93 -5
- package/dist/lib/dashboard/types.js +133 -4
- package/dist/lib/git.d.ts +1 -1
- package/dist/lib/git.js +7 -1
- package/dist/lib/trackers/linear/api.d.ts +2 -1
- package/dist/lib/trackers/linear/api.js +137 -1
- package/dist/lib/trackers/linear/index.js +17 -1
- package/dist/lib/trackers/types.d.ts +58 -0
- package/dist/lib/trackers/types.js +5 -1
- package/package.json +1 -1
- package/prompts/ask.njk +10 -0
|
@@ -10,11 +10,12 @@ 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 } from "../lib/ai.js";
|
|
13
|
+
import { resolveAgentBinary, fillCommitMessage, askTicketQuestion } from "../lib/ai.js";
|
|
14
14
|
import { getInstalledClaudeVersion } from "../lib/version.js";
|
|
15
15
|
import { extractTicketId, getStagedDiffContent } from "../lib/git.js";
|
|
16
16
|
import { getMultiplexer } from "../lib/multiplexer/index.js";
|
|
17
17
|
import { shellEscape } from "../lib/multiplexer/types.js";
|
|
18
|
+
import Spinner from "ink-spinner";
|
|
18
19
|
import SquirrelLoader from "../lib/squirrel-loader.js";
|
|
19
20
|
import { getPRTemplate } from "../lib/github.js";
|
|
20
21
|
import { renderPrompt, renderDiff, renderTicket } from "../lib/prompts.js";
|
|
@@ -30,6 +31,7 @@ import { loadDashboardData, loadReviewsData } from "../lib/dashboard/data.js";
|
|
|
30
31
|
import IssueList, { buildIssueListRows } from "../lib/dashboard/IssueList.js";
|
|
31
32
|
import { detectTerminalTheme, getThemeForMode, } from "../lib/dashboard/theme.js";
|
|
32
33
|
import DetailPanel, { buildIssueActions } from "../lib/dashboard/DetailPanel.js";
|
|
34
|
+
import TriageScheduleOverlay, { buildScheduleLines, } from "../lib/dashboard/TriageScheduleOverlay.js";
|
|
33
35
|
import ReviewList from "../lib/dashboard/ReviewList.js";
|
|
34
36
|
import ReviewDetailPanel, { buildReviewActions } from "../lib/dashboard/ReviewDetailPanel.js";
|
|
35
37
|
import { CommitOverlay, PrCreateOverlay, HelpOverlay } from "../lib/dashboard/Overlays.js";
|
|
@@ -319,6 +321,10 @@ export default function Dashboard() {
|
|
|
319
321
|
// `.code-workspace` file (VSCode/Cursor) AND such a file exists in the
|
|
320
322
|
// repo root. Recomputed alongside the data refresh.
|
|
321
323
|
const [hasWorkspaceFile, setHasWorkspaceFile] = useState(false);
|
|
324
|
+
// Whether the active tracker has a triage inbox (Linear). Drives whether the
|
|
325
|
+
// Triage tab appears at all. Recomputed on every data refresh — feature
|
|
326
|
+
// detection via `tracker.supportsTriage`, never a kind check.
|
|
327
|
+
const [supportsTriage, setSupportsTriage] = useState(false);
|
|
322
328
|
const refreshTimerRef = useRef(null);
|
|
323
329
|
const repoRootRef = useRef(null);
|
|
324
330
|
const stateRef = useRef(state);
|
|
@@ -399,12 +405,14 @@ export default function Dashboard() {
|
|
|
399
405
|
}
|
|
400
406
|
try {
|
|
401
407
|
// Re-detect terminal theme alongside data fetch so light↔dark
|
|
402
|
-
// switches propagate within one refresh cycle (≤
|
|
408
|
+
// switches propagate within one refresh cycle (≤5min, or sooner
|
|
409
|
+
// on a manual `R`). Skip the
|
|
403
410
|
// OSC 11 query when a text-input overlay is active — the
|
|
404
411
|
// terminal's response would otherwise leak into the user's
|
|
405
412
|
// commit/PR/context message via Ink's stdin handler.
|
|
406
413
|
const overlay = stateRef.current.overlay;
|
|
407
414
|
const inTextInput = overlay === "context-input" ||
|
|
415
|
+
(overlay === "triage-ask" && stateRef.current.triageAskPhase === "input") ||
|
|
408
416
|
(overlay === "pr-create" && stateRef.current.prCreatePhase === "review") ||
|
|
409
417
|
(overlay === "commit" && stateRef.current.commitPhase === "awaiting-message");
|
|
410
418
|
const themeP = inTextInput ? Promise.resolve(null) : detectTerminalTheme();
|
|
@@ -430,8 +438,18 @@ export default function Dashboard() {
|
|
|
430
438
|
}
|
|
431
439
|
}
|
|
432
440
|
setHasWorkspaceFile(hasWs);
|
|
441
|
+
const tracker = getIssueTracker(repoRoot);
|
|
442
|
+
setSupportsTriage(tracker.supportsTriage === true);
|
|
433
443
|
dispatch({ type: "SET_DATA", ...data });
|
|
434
444
|
dispatch({ type: "SET_REVIEWS_DATA", flatReviews: reviewData.flatReviews });
|
|
445
|
+
// Triage on-call rotations (Linear). Best-effort, non-blocking — never
|
|
446
|
+
// fails the refresh.
|
|
447
|
+
if (tracker.getTriageSchedules) {
|
|
448
|
+
tracker
|
|
449
|
+
.getTriageSchedules(repoRoot)
|
|
450
|
+
.then((schedules) => dispatch({ type: "SET_TRIAGE_SCHEDULES", schedules }))
|
|
451
|
+
.catch(() => { });
|
|
452
|
+
}
|
|
435
453
|
}
|
|
436
454
|
catch (e) {
|
|
437
455
|
dispatch({
|
|
@@ -539,20 +557,34 @@ export default function Dashboard() {
|
|
|
539
557
|
return;
|
|
540
558
|
}
|
|
541
559
|
{
|
|
542
|
-
const
|
|
543
|
-
const flat =
|
|
544
|
-
const idx =
|
|
545
|
-
|
|
560
|
+
const tab = s.activeTab;
|
|
561
|
+
const flat = tab === "trees" ? s.flatTrees : tab === "triage" ? s.flatTriage : s.flatIssues;
|
|
562
|
+
const idx = tab === "trees"
|
|
563
|
+
? s.treeSelectedIndex
|
|
564
|
+
: tab === "triage"
|
|
565
|
+
? s.triageSelectedIndex
|
|
566
|
+
: s.selectedIndex;
|
|
567
|
+
const detailOff = tab === "trees"
|
|
568
|
+
? s.treeDetailScrollOffset
|
|
569
|
+
: tab === "triage"
|
|
570
|
+
? s.triageDetailScrollOffset
|
|
571
|
+
: s.detailScrollOffset;
|
|
572
|
+
const selectType = tab === "trees" ? "TREE_SELECT" : tab === "triage" ? "TRIAGE_SELECT" : "SELECT";
|
|
573
|
+
const scrollType = tab === "trees"
|
|
574
|
+
? "TREE_SCROLL_DETAIL"
|
|
575
|
+
: tab === "triage"
|
|
576
|
+
? "TRIAGE_SCROLL_DETAIL"
|
|
577
|
+
: "SCROLL_DETAIL";
|
|
546
578
|
if (inLeftPane) {
|
|
547
579
|
const maxIdx = flat.length - 1;
|
|
548
580
|
if (maxIdx < 0)
|
|
549
581
|
return;
|
|
550
582
|
const next = Math.max(0, Math.min(idx + delta, maxIdx));
|
|
551
|
-
dispatch({ type:
|
|
583
|
+
dispatch({ type: selectType, index: next });
|
|
552
584
|
}
|
|
553
585
|
else {
|
|
554
586
|
const next = Math.max(0, detailOff + delta);
|
|
555
|
-
dispatch({ type:
|
|
587
|
+
dispatch({ type: scrollType, offset: next });
|
|
556
588
|
}
|
|
557
589
|
}
|
|
558
590
|
return;
|
|
@@ -608,6 +640,10 @@ export default function Dashboard() {
|
|
|
608
640
|
const s = stateRef.current;
|
|
609
641
|
if (s.loading || s.error)
|
|
610
642
|
return;
|
|
643
|
+
// Full-area triage overlays cover the content area — don't let a click
|
|
644
|
+
// fall through to the (hidden) list underneath.
|
|
645
|
+
if (s.overlay === "triage-ask" || s.overlay === "triage-schedule")
|
|
646
|
+
return;
|
|
611
647
|
if (col < 2 || col > lw + 1)
|
|
612
648
|
return;
|
|
613
649
|
// Row 1 = title, row 2 = tab strip, row 3 = top border, content starts at row 4 (1-based)
|
|
@@ -626,16 +662,21 @@ export default function Dashboard() {
|
|
|
626
662
|
return;
|
|
627
663
|
}
|
|
628
664
|
{
|
|
629
|
-
const
|
|
630
|
-
const flat =
|
|
631
|
-
const grps =
|
|
632
|
-
const scrollOff =
|
|
665
|
+
const tab = s.activeTab;
|
|
666
|
+
const flat = tab === "trees" ? s.flatTrees : tab === "triage" ? s.flatTriage : s.flatIssues;
|
|
667
|
+
const grps = tab === "trees" ? s.treeGroups : tab === "triage" ? s.triageGroups : s.groups;
|
|
668
|
+
const scrollOff = tab === "trees"
|
|
669
|
+
? s.treeListScrollOffset
|
|
670
|
+
: tab === "triage"
|
|
671
|
+
? s.triageListScrollOffset
|
|
672
|
+
: s.listScrollOffset;
|
|
673
|
+
const selectType = tab === "trees" ? "TREE_SELECT" : tab === "triage" ? "TRIAGE_SELECT" : "SELECT";
|
|
633
674
|
if (flat.length === 0)
|
|
634
675
|
return;
|
|
635
676
|
const listRow = scrollOff + contentRow;
|
|
636
677
|
const flatIdx = getFlatIndexForListRow(grps, flat, listRow);
|
|
637
678
|
if (flatIdx !== null && flatIdx >= 0 && flatIdx < flat.length) {
|
|
638
|
-
dispatch({ type:
|
|
679
|
+
dispatch({ type: selectType, index: flatIdx });
|
|
639
680
|
}
|
|
640
681
|
}
|
|
641
682
|
};
|
|
@@ -647,16 +688,21 @@ export default function Dashboard() {
|
|
|
647
688
|
await refresh(true);
|
|
648
689
|
};
|
|
649
690
|
init();
|
|
650
|
-
// Auto-refresh every
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
691
|
+
// Auto-refresh every 5 minutes. Each refresh fans out into several
|
|
692
|
+
// `gh pr view`/`gh pr checks` calls per worktree-PR plus the reviews
|
|
693
|
+
// tab, all on the GraphQL API (5000-point/hour budget) — a 30s cadence
|
|
694
|
+
// drained it within the hour when the dashboard was left open. Press
|
|
695
|
+
// `R` for an on-demand refresh between cycles. While the diff overlay
|
|
696
|
+
// is open, also bump the diff refresh tick so new/removed files
|
|
697
|
+
// (created or deleted outside the dashboard) eventually show up.
|
|
698
|
+
// Stage/unstage already patch XY in place, so this is purely about
|
|
699
|
+
// file-set drift.
|
|
654
700
|
refreshTimerRef.current = setInterval(() => {
|
|
655
701
|
refresh();
|
|
656
702
|
if (stateRef.current.overlay === "diff") {
|
|
657
703
|
dispatch({ type: "DIFF_REFRESH_FILES" });
|
|
658
704
|
}
|
|
659
|
-
},
|
|
705
|
+
}, 300_000);
|
|
660
706
|
return () => {
|
|
661
707
|
if (refreshTimerRef.current)
|
|
662
708
|
clearInterval(refreshTimerRef.current);
|
|
@@ -698,6 +744,72 @@ export default function Dashboard() {
|
|
|
698
744
|
dispatch({ type: "TREE_SCROLL_LIST", offset });
|
|
699
745
|
}
|
|
700
746
|
}, [state.treeSelectedIndex, state.treeGroups, contentHeight, state.treeListScrollOffset]);
|
|
747
|
+
// ── Triage list scroll tracking ──────────────────────────────────
|
|
748
|
+
useEffect(() => {
|
|
749
|
+
const rowIdx = getRowIndexForFlatIndex(state.triageGroups, state.flatTriage, state.triageSelectedIndex);
|
|
750
|
+
const maxVisible = contentHeight - LIST_FOOTER_HEIGHT;
|
|
751
|
+
let offset = state.triageListScrollOffset;
|
|
752
|
+
if (rowIdx < offset) {
|
|
753
|
+
offset = Math.max(0, rowIdx - 1);
|
|
754
|
+
}
|
|
755
|
+
else if (rowIdx >= offset + maxVisible) {
|
|
756
|
+
offset = rowIdx - maxVisible + 2;
|
|
757
|
+
}
|
|
758
|
+
if (offset !== state.triageListScrollOffset) {
|
|
759
|
+
dispatch({ type: "TRIAGE_SCROLL_LIST", offset });
|
|
760
|
+
}
|
|
761
|
+
}, [state.triageSelectedIndex, state.triageGroups, contentHeight, state.triageListScrollOffset]);
|
|
762
|
+
// ── Triage comment lazy-load ─────────────────────────────────────
|
|
763
|
+
// The assigned-issues list query doesn't include comments — fetch the full
|
|
764
|
+
// issue (with discussion) for the selected triage row on demand and cache it
|
|
765
|
+
// by identifier so the detail panel can show the thread without re-fetching
|
|
766
|
+
// on every keypress.
|
|
767
|
+
useEffect(() => {
|
|
768
|
+
if (state.activeTab !== "triage")
|
|
769
|
+
return;
|
|
770
|
+
const di = state.flatTriage[state.triageSelectedIndex];
|
|
771
|
+
if (!di)
|
|
772
|
+
return;
|
|
773
|
+
const id = di.issue.identifier;
|
|
774
|
+
if (state.triageCommentsById[id] !== undefined)
|
|
775
|
+
return;
|
|
776
|
+
const root = repoRootRef.current;
|
|
777
|
+
if (!root)
|
|
778
|
+
return;
|
|
779
|
+
let cancelled = false;
|
|
780
|
+
void (async () => {
|
|
781
|
+
try {
|
|
782
|
+
const tracker = getIssueTracker(root);
|
|
783
|
+
const res = await tracker.getIssue(id, root);
|
|
784
|
+
if (cancelled)
|
|
785
|
+
return;
|
|
786
|
+
dispatch({
|
|
787
|
+
type: "TRIAGE_COMMENTS_LOADED",
|
|
788
|
+
id,
|
|
789
|
+
comments: res.ok ? res.value.comments : [],
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
if (!cancelled)
|
|
794
|
+
dispatch({ type: "TRIAGE_COMMENTS_LOADED", id, comments: [] });
|
|
795
|
+
}
|
|
796
|
+
})();
|
|
797
|
+
return () => {
|
|
798
|
+
cancelled = true;
|
|
799
|
+
};
|
|
800
|
+
}, [state.activeTab, state.triageSelectedIndex, state.flatTriage, state.triageCommentsById]);
|
|
801
|
+
// ── Keep activeTab valid ─────────────────────────────────────────
|
|
802
|
+
// The Triage tab can disappear when the tracker is switched away from one
|
|
803
|
+
// that supports it. If the active tab is no longer in the order, fall back
|
|
804
|
+
// to the first tab so the strip + content don't end up on a dead tab.
|
|
805
|
+
useEffect(() => {
|
|
806
|
+
const order = supportsTriage
|
|
807
|
+
? ["triage", "issues", "trees", "reviews"]
|
|
808
|
+
: ["issues", "trees", "reviews"];
|
|
809
|
+
if (!order.includes(state.activeTab)) {
|
|
810
|
+
dispatch({ type: "SET_TAB", tab: order[0] });
|
|
811
|
+
}
|
|
812
|
+
}, [supportsTriage, state.activeTab]);
|
|
701
813
|
// ── Review list scroll tracking ──────────────────────────────────
|
|
702
814
|
useEffect(() => {
|
|
703
815
|
// Row index = 1 (column header) + flatIndex
|
|
@@ -721,6 +833,7 @@ export default function Dashboard() {
|
|
|
721
833
|
// Disable tracking while any text-input overlay is mounted; restore on exit.
|
|
722
834
|
useEffect(() => {
|
|
723
835
|
const needsMouseOff = state.overlay === "context-input" ||
|
|
836
|
+
(state.overlay === "triage-ask" && state.triageAskPhase === "input") ||
|
|
724
837
|
(state.overlay === "issue-form" && state.issueFormPhase !== "saving") ||
|
|
725
838
|
(state.overlay === "pr-create" && state.prCreatePhase === "review") ||
|
|
726
839
|
(state.overlay === "commit" && state.commitPhase === "awaiting-message");
|
|
@@ -730,7 +843,13 @@ export default function Dashboard() {
|
|
|
730
843
|
return () => {
|
|
731
844
|
process.stdout.write("\x1b[?1002h\x1b[?1006h");
|
|
732
845
|
};
|
|
733
|
-
}, [
|
|
846
|
+
}, [
|
|
847
|
+
state.overlay,
|
|
848
|
+
state.triageAskPhase,
|
|
849
|
+
state.issueFormPhase,
|
|
850
|
+
state.prCreatePhase,
|
|
851
|
+
state.commitPhase,
|
|
852
|
+
]);
|
|
734
853
|
// ── Diff overlay: load file list when opened (gh pr diff path) ────
|
|
735
854
|
// Reviews-tab PRs without a local worktree shell out to `gh pr diff <n>`,
|
|
736
855
|
// parse the unified blob into per-file records, and stash the per-file
|
|
@@ -1021,7 +1140,9 @@ export default function Dashboard() {
|
|
|
1021
1140
|
const sref = stateRef.current;
|
|
1022
1141
|
const di = sref.activeTab === "trees"
|
|
1023
1142
|
? sref.flatTrees[sref.treeSelectedIndex]
|
|
1024
|
-
: sref.
|
|
1143
|
+
: sref.activeTab === "triage"
|
|
1144
|
+
? sref.flatTriage[sref.triageSelectedIndex]
|
|
1145
|
+
: sref.flatIssues[sref.selectedIndex];
|
|
1025
1146
|
if (!di)
|
|
1026
1147
|
return;
|
|
1027
1148
|
const repoRoot = repoRootRef.current;
|
|
@@ -1132,7 +1253,9 @@ export default function Dashboard() {
|
|
|
1132
1253
|
const doWork = useCallback((mode, customContext) => {
|
|
1133
1254
|
const di = state.activeTab === "trees"
|
|
1134
1255
|
? state.flatTrees[state.treeSelectedIndex]
|
|
1135
|
-
: state.
|
|
1256
|
+
: state.activeTab === "triage"
|
|
1257
|
+
? state.flatTriage[state.triageSelectedIndex]
|
|
1258
|
+
: state.flatIssues[state.selectedIndex];
|
|
1136
1259
|
if (!di)
|
|
1137
1260
|
return;
|
|
1138
1261
|
const repoRoot = repoRootRef.current;
|
|
@@ -1181,12 +1304,62 @@ export default function Dashboard() {
|
|
|
1181
1304
|
state.selectedIndex,
|
|
1182
1305
|
state.flatTrees,
|
|
1183
1306
|
state.treeSelectedIndex,
|
|
1307
|
+
state.flatTriage,
|
|
1308
|
+
state.triageSelectedIndex,
|
|
1184
1309
|
state.activeTab,
|
|
1185
1310
|
exit,
|
|
1186
1311
|
launchWorkInTmux,
|
|
1187
1312
|
proceedAfterBaseSelect,
|
|
1188
1313
|
writeContextFile,
|
|
1189
1314
|
]);
|
|
1315
|
+
// ── Triage: ask Claude a clarifying question ─────────────────────
|
|
1316
|
+
// One-shot, read-only Q&A over the selected triage issue plus its full
|
|
1317
|
+
// comment thread. Runs non-interactively (async so the spinner keeps
|
|
1318
|
+
// animating) and shows the answer in the overlay.
|
|
1319
|
+
const doAsk = useCallback(async (question) => {
|
|
1320
|
+
const s = stateRef.current;
|
|
1321
|
+
const di = s.flatTriage[s.triageSelectedIndex];
|
|
1322
|
+
const root = repoRootRef.current;
|
|
1323
|
+
if (!di || !root)
|
|
1324
|
+
return;
|
|
1325
|
+
dispatch({ type: "TRIAGE_ASK_RUN" });
|
|
1326
|
+
try {
|
|
1327
|
+
const tracker = getIssueTracker(root);
|
|
1328
|
+
const res = await tracker.getIssue(di.issue.identifier, root);
|
|
1329
|
+
if (!res.ok) {
|
|
1330
|
+
dispatch({
|
|
1331
|
+
type: "TRIAGE_ASK_ERROR",
|
|
1332
|
+
error: res.message ?? `Could not load ${di.issue.identifier}`,
|
|
1333
|
+
});
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
// Cache the freshly-fetched comments so the detail panel reflects them too.
|
|
1337
|
+
dispatch({
|
|
1338
|
+
type: "TRIAGE_COMMENTS_LOADED",
|
|
1339
|
+
id: di.issue.identifier,
|
|
1340
|
+
comments: res.value.comments,
|
|
1341
|
+
});
|
|
1342
|
+
const result = await askTicketQuestion({
|
|
1343
|
+
ticket: res.value,
|
|
1344
|
+
trackerName: tracker.displayName,
|
|
1345
|
+
question,
|
|
1346
|
+
});
|
|
1347
|
+
if (!result.success || !result.output) {
|
|
1348
|
+
dispatch({
|
|
1349
|
+
type: "TRIAGE_ASK_ERROR",
|
|
1350
|
+
error: result.output || "Claude returned no answer (is the CLI authenticated?)",
|
|
1351
|
+
});
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
dispatch({ type: "TRIAGE_ASK_ANSWER", answer: result.output });
|
|
1355
|
+
}
|
|
1356
|
+
catch (e) {
|
|
1357
|
+
dispatch({
|
|
1358
|
+
type: "TRIAGE_ASK_ERROR",
|
|
1359
|
+
error: e instanceof Error ? e.message : "Ask failed",
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
}, []);
|
|
1190
1363
|
// ── Tracker selection ────────────────────────────────────────────
|
|
1191
1364
|
// Mirrors `santree issue setup`. Local needs no account; Linear picks an
|
|
1192
1365
|
// authenticated workspace (sub-list when >1); GitHub verifies `gh`.
|
|
@@ -1451,6 +1624,25 @@ export default function Dashboard() {
|
|
|
1451
1624
|
dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to open workspace" });
|
|
1452
1625
|
}
|
|
1453
1626
|
}, []);
|
|
1627
|
+
// ── Worktree removal ─────────────────────────────────────────────
|
|
1628
|
+
// Runs a single deletion to completion, streaming staged progress into
|
|
1629
|
+
// `deletingTickets[ticketId]` (rendered in the detail pane). Designed to be
|
|
1630
|
+
// fired without awaiting so several worktrees can be removed concurrently —
|
|
1631
|
+
// all args are captured, nothing is read from state inside.
|
|
1632
|
+
const removeWorktreeWithProgress = useCallback(async (ticketId, branch, repoRoot, force) => {
|
|
1633
|
+
dispatch({ type: "DELETE_START", ticketId });
|
|
1634
|
+
const result = await removeWorktree(branch, repoRoot, force, (msg) => dispatch({ type: "DELETE_LOG", ticketId, logs: `${msg}\n` }));
|
|
1635
|
+
if (result.success) {
|
|
1636
|
+
dispatch({ type: "DELETE_DONE", ticketId });
|
|
1637
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: `Removed worktree for ${ticketId}` });
|
|
1638
|
+
refresh();
|
|
1639
|
+
}
|
|
1640
|
+
else {
|
|
1641
|
+
const error = result.error ?? "Unknown error";
|
|
1642
|
+
dispatch({ type: "DELETE_ERROR", ticketId, error });
|
|
1643
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: `Failed to remove ${ticketId}: ${error}` });
|
|
1644
|
+
}
|
|
1645
|
+
}, [refresh]);
|
|
1454
1646
|
// ── PR create flow ───────────────────────────────────────────────
|
|
1455
1647
|
const doPrCreate = useCallback(async (fill) => {
|
|
1456
1648
|
const s = stateRef.current;
|
|
@@ -1567,7 +1759,8 @@ export default function Dashboard() {
|
|
|
1567
1759
|
try {
|
|
1568
1760
|
const bodyFile = path.join(os.tmpdir(), `santree-pr-${Date.now()}.md`);
|
|
1569
1761
|
fs.writeFileSync(bodyFile, s.prCreateBody);
|
|
1570
|
-
const
|
|
1762
|
+
const draftFlag = s.prCreateDraft ? " --draft" : "";
|
|
1763
|
+
const { stdout } = await execAsync(`gh pr create --title "${s.prCreateTitle.replace(/"/g, '\\"')}" --base "${base}" --head "${s.prCreateBranch}" --body-file "${bodyFile}"${draftFlag}`, { cwd });
|
|
1571
1764
|
try {
|
|
1572
1765
|
fs.unlinkSync(bodyFile);
|
|
1573
1766
|
}
|
|
@@ -1589,8 +1782,25 @@ export default function Dashboard() {
|
|
|
1589
1782
|
return;
|
|
1590
1783
|
const base = getBaseBranch(s.prCreateBranch);
|
|
1591
1784
|
const cwd = s.prCreateWorktreePath;
|
|
1785
|
+
// Carry the edited title/body into GitHub's compose page so the browser
|
|
1786
|
+
// opens pre-filled (gh passes them as URL query params). Without this,
|
|
1787
|
+
// the fill→"open in browser" path would drop everything the user just
|
|
1788
|
+
// reviewed. Note: very long bodies can be truncated by GitHub's URL
|
|
1789
|
+
// length limit — gh's documented `--web` behavior; the editable compose
|
|
1790
|
+
// page is the fallback. Draft selection lives in the browser dropdown,
|
|
1791
|
+
// since `gh --web` doesn't accept `--draft`.
|
|
1792
|
+
let bodyFile = null;
|
|
1592
1793
|
try {
|
|
1593
|
-
|
|
1794
|
+
let cmd = `gh pr create --web --base "${base}" --head "${s.prCreateBranch}"`;
|
|
1795
|
+
if (s.prCreateTitle) {
|
|
1796
|
+
cmd += ` --title "${s.prCreateTitle.replace(/"/g, '\\"')}"`;
|
|
1797
|
+
}
|
|
1798
|
+
if (s.prCreateBody) {
|
|
1799
|
+
bodyFile = path.join(os.tmpdir(), `santree-pr-${Date.now()}.md`);
|
|
1800
|
+
fs.writeFileSync(bodyFile, s.prCreateBody);
|
|
1801
|
+
cmd += ` --body-file "${bodyFile}"`;
|
|
1802
|
+
}
|
|
1803
|
+
await execAsync(cmd, { cwd });
|
|
1594
1804
|
dispatch({ type: "PR_CREATE_DONE", url: "" });
|
|
1595
1805
|
setTimeout(() => {
|
|
1596
1806
|
dispatch({ type: "PR_CREATE_CANCEL" });
|
|
@@ -1601,8 +1811,21 @@ export default function Dashboard() {
|
|
|
1601
1811
|
const msg = e?.stderr?.trim() || e?.message || "Failed to open in browser";
|
|
1602
1812
|
dispatch({ type: "PR_CREATE_ERROR", error: msg });
|
|
1603
1813
|
}
|
|
1814
|
+
finally {
|
|
1815
|
+
if (bodyFile) {
|
|
1816
|
+
try {
|
|
1817
|
+
fs.unlinkSync(bodyFile);
|
|
1818
|
+
}
|
|
1819
|
+
catch { }
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1604
1822
|
}, [refresh]);
|
|
1605
1823
|
// ── Keyboard ──────────────────────────────────────────────────────
|
|
1824
|
+
// Tab order + numeric keybinds. Triage leads (it's the inbox) but only
|
|
1825
|
+
// appears when the active tracker has a triage concept.
|
|
1826
|
+
const tabOrder = supportsTriage
|
|
1827
|
+
? ["triage", "issues", "trees", "reviews"]
|
|
1828
|
+
: ["issues", "trees", "reviews"];
|
|
1606
1829
|
useInput((input, key) => {
|
|
1607
1830
|
// Clear action messages on any keypress
|
|
1608
1831
|
if (state.actionMessage && input !== "q") {
|
|
@@ -1682,6 +1905,10 @@ export default function Dashboard() {
|
|
|
1682
1905
|
confirmPrCreate();
|
|
1683
1906
|
return;
|
|
1684
1907
|
}
|
|
1908
|
+
if (input === "d") {
|
|
1909
|
+
dispatch({ type: "PR_CREATE_TOGGLE_DRAFT" });
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1685
1912
|
if (input === "e") {
|
|
1686
1913
|
dispatch({ type: "PR_CREATE_EDIT" });
|
|
1687
1914
|
return;
|
|
@@ -1776,6 +2003,60 @@ export default function Dashboard() {
|
|
|
1776
2003
|
if (state.overlay === "context-input") {
|
|
1777
2004
|
return;
|
|
1778
2005
|
}
|
|
2006
|
+
// Triage "ask Claude" overlay. The input phase is owned by
|
|
2007
|
+
// MultilineTextArea (outer disabled via isActive); running swallows
|
|
2008
|
+
// keys; answer/error are navigable + closable here.
|
|
2009
|
+
if (state.overlay === "triage-ask") {
|
|
2010
|
+
if (state.triageAskPhase === "input" || state.triageAskPhase === "running")
|
|
2011
|
+
return;
|
|
2012
|
+
if (key.escape || input === "q") {
|
|
2013
|
+
dispatch({ type: "TRIAGE_ASK_CLOSE" });
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
if (input === "a") {
|
|
2017
|
+
// Ask another question about the same issue (resets to input).
|
|
2018
|
+
dispatch({ type: "TRIAGE_ASK_OPEN", ticketId: state.triageAskTicketId ?? "" });
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
if (state.triageAskPhase === "answer") {
|
|
2022
|
+
if ((key.shift && key.downArrow) || input === "j") {
|
|
2023
|
+
dispatch({ type: "TRIAGE_ASK_SCROLL", offset: state.triageAskScrollOffset + 3 });
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if ((key.shift && key.upArrow) || input === "k") {
|
|
2027
|
+
dispatch({
|
|
2028
|
+
type: "TRIAGE_ASK_SCROLL",
|
|
2029
|
+
offset: Math.max(0, state.triageAskScrollOffset - 3),
|
|
2030
|
+
});
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
// Triage on-call schedule overlay — read-only; scroll + close.
|
|
2037
|
+
if (state.overlay === "triage-schedule") {
|
|
2038
|
+
if (key.escape || input === "q") {
|
|
2039
|
+
dispatch({ type: "TRIAGE_SCHEDULE_CLOSE" });
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
const total = buildScheduleLines(state.triageSchedules).length;
|
|
2043
|
+
const maxOffset = Math.max(0, total - Math.max(1, contentHeight - 3));
|
|
2044
|
+
if (input === "j" || key.downArrow) {
|
|
2045
|
+
dispatch({
|
|
2046
|
+
type: "TRIAGE_SCHEDULE_SCROLL",
|
|
2047
|
+
offset: Math.min(maxOffset, state.triageScheduleScrollOffset + 3),
|
|
2048
|
+
});
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
if (input === "k" || key.upArrow) {
|
|
2052
|
+
dispatch({
|
|
2053
|
+
type: "TRIAGE_SCHEDULE_SCROLL",
|
|
2054
|
+
offset: Math.max(0, state.triageScheduleScrollOffset - 3),
|
|
2055
|
+
});
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
1779
2060
|
// Diff overlay
|
|
1780
2061
|
if (state.overlay === "diff") {
|
|
1781
2062
|
// Pending discard modal — intercepts y/n/ESC/q so they don't
|
|
@@ -1967,30 +2248,17 @@ export default function Dashboard() {
|
|
|
1967
2248
|
if (state.overlay === "confirm-delete") {
|
|
1968
2249
|
if (input === "y") {
|
|
1969
2250
|
dispatch({ type: "SET_OVERLAY", overlay: null });
|
|
1970
|
-
// Worktree removal is a Trees-tab action.
|
|
2251
|
+
// Worktree removal is a Trees-tab action. Fire-and-forget so the
|
|
2252
|
+
// user can immediately confirm another deletion — each removal
|
|
2253
|
+
// closes over its own ticket/branch and streams progress into
|
|
2254
|
+
// `deletingTickets[ticketId]`, rendered in the detail pane.
|
|
1971
2255
|
const di = state.flatTrees[state.treeSelectedIndex];
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
dispatch({ type: "DELETE_DONE" });
|
|
1979
|
-
if (result.success) {
|
|
1980
|
-
dispatch({
|
|
1981
|
-
type: "SET_ACTION_MESSAGE",
|
|
1982
|
-
message: `Removed worktree for ${di.issue.identifier}`,
|
|
1983
|
-
});
|
|
1984
|
-
refresh();
|
|
1985
|
-
}
|
|
1986
|
-
else {
|
|
1987
|
-
dispatch({
|
|
1988
|
-
type: "SET_ACTION_MESSAGE",
|
|
1989
|
-
message: `Failed: ${result.error ?? "Unknown error"}`,
|
|
1990
|
-
});
|
|
1991
|
-
}
|
|
1992
|
-
});
|
|
1993
|
-
}
|
|
2256
|
+
const repoRoot = repoRootRef.current;
|
|
2257
|
+
if (di?.worktree && repoRoot) {
|
|
2258
|
+
const ticketId = di.issue.identifier;
|
|
2259
|
+
const branch = di.worktree.branch;
|
|
2260
|
+
const force = di.worktree.dirty;
|
|
2261
|
+
void removeWorktreeWithProgress(ticketId, branch, repoRoot, force);
|
|
1994
2262
|
}
|
|
1995
2263
|
return;
|
|
1996
2264
|
}
|
|
@@ -2075,24 +2343,20 @@ export default function Dashboard() {
|
|
|
2075
2343
|
if (state.overlay === "issue-form") {
|
|
2076
2344
|
return;
|
|
2077
2345
|
}
|
|
2078
|
-
// Tab switching (only when no overlay is active)
|
|
2079
|
-
|
|
2346
|
+
// Tab switching (only when no overlay is active). Order/count depend on
|
|
2347
|
+
// whether the Triage tab is present (`tabOrder`).
|
|
2080
2348
|
if (key.tab) {
|
|
2081
|
-
const idx =
|
|
2082
|
-
dispatch({ type: "SET_TAB", tab:
|
|
2083
|
-
return;
|
|
2084
|
-
}
|
|
2085
|
-
if (input === "1") {
|
|
2086
|
-
dispatch({ type: "SET_TAB", tab: "issues" });
|
|
2349
|
+
const idx = tabOrder.indexOf(state.activeTab);
|
|
2350
|
+
dispatch({ type: "SET_TAB", tab: tabOrder[(idx + 1) % tabOrder.length] });
|
|
2087
2351
|
return;
|
|
2088
2352
|
}
|
|
2089
|
-
if (input
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2353
|
+
if (input >= "1" && input <= "9") {
|
|
2354
|
+
const n = Number(input) - 1;
|
|
2355
|
+
const tab = tabOrder[n];
|
|
2356
|
+
if (tab) {
|
|
2357
|
+
dispatch({ type: "SET_TAB", tab });
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2096
2360
|
}
|
|
2097
2361
|
// Reopen tracker selection from any tab.
|
|
2098
2362
|
if (input === "t") {
|
|
@@ -2330,20 +2594,80 @@ export default function Dashboard() {
|
|
|
2330
2594
|
const ticketId = extractTicketId(ri.branch);
|
|
2331
2595
|
if (!ticketId)
|
|
2332
2596
|
return;
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2597
|
+
void removeWorktreeWithProgress(ticketId, ri.branch, repoRoot, ri.worktree.dirty);
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
return; // prevent fallthrough to issues actions
|
|
2601
|
+
}
|
|
2602
|
+
// ── Triage tab keyboard ──────────────────────────────────
|
|
2603
|
+
// Inbox issues: navigate, read comments (detail pane), ask Claude a
|
|
2604
|
+
// question (`a`), or send it to a tree (`w`, same as Issues tab).
|
|
2605
|
+
if (state.activeTab === "triage") {
|
|
2606
|
+
const maxIdx = state.flatTriage.length - 1;
|
|
2607
|
+
if (input === "j" || (key.downArrow && !key.shift)) {
|
|
2608
|
+
dispatch({
|
|
2609
|
+
type: "TRIAGE_SELECT",
|
|
2610
|
+
index: Math.min(state.triageSelectedIndex + 1, maxIdx),
|
|
2344
2611
|
});
|
|
2345
2612
|
return;
|
|
2346
2613
|
}
|
|
2614
|
+
if (input === "k" || (key.upArrow && !key.shift)) {
|
|
2615
|
+
dispatch({
|
|
2616
|
+
type: "TRIAGE_SELECT",
|
|
2617
|
+
index: Math.max(state.triageSelectedIndex - 1, 0),
|
|
2618
|
+
});
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
if (key.shift && key.downArrow) {
|
|
2622
|
+
dispatch({
|
|
2623
|
+
type: "TRIAGE_SCROLL_DETAIL",
|
|
2624
|
+
offset: state.triageDetailScrollOffset + 3,
|
|
2625
|
+
});
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
if (key.shift && key.upArrow) {
|
|
2629
|
+
dispatch({
|
|
2630
|
+
type: "TRIAGE_SCROLL_DETAIL",
|
|
2631
|
+
offset: Math.max(0, state.triageDetailScrollOffset - 3),
|
|
2632
|
+
});
|
|
2633
|
+
return;
|
|
2634
|
+
}
|
|
2635
|
+
// On-call schedule overlay — works even with an empty inbox.
|
|
2636
|
+
if (input === "s") {
|
|
2637
|
+
dispatch({ type: "TRIAGE_SCHEDULE_OPEN" });
|
|
2638
|
+
return;
|
|
2639
|
+
}
|
|
2640
|
+
const di = state.flatTriage[state.triageSelectedIndex];
|
|
2641
|
+
if (!di)
|
|
2642
|
+
return;
|
|
2643
|
+
// Send to tree — reuses the Issues-tab worktree-creation flow
|
|
2644
|
+
// (mode-select → context-input → createAndLaunch).
|
|
2645
|
+
if (input === "w") {
|
|
2646
|
+
if (di.worktree?.sessionId) {
|
|
2647
|
+
dispatch({
|
|
2648
|
+
type: "SET_ACTION_MESSAGE",
|
|
2649
|
+
message: "Session active — switch to the Trees tab to resume.",
|
|
2650
|
+
});
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
dispatch({ type: "SET_OVERLAY", overlay: "mode-select" });
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
// Ask Claude a clarifying question about this issue + its comments.
|
|
2657
|
+
if (input === "a") {
|
|
2658
|
+
dispatch({ type: "TRIAGE_ASK_OPEN", ticketId: di.issue.identifier });
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
if (input === "o") {
|
|
2662
|
+
if (!di.issue.url) {
|
|
2663
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No issue URL available" });
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
if (openUrl(di.issue.url)) {
|
|
2667
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened in browser" });
|
|
2668
|
+
}
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2347
2671
|
return; // prevent fallthrough to issues actions
|
|
2348
2672
|
}
|
|
2349
2673
|
// Issues tab = backlog/planning; Trees tab = worktrees in
|
|
@@ -2663,11 +2987,18 @@ export default function Dashboard() {
|
|
|
2663
2987
|
dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to remove" });
|
|
2664
2988
|
return;
|
|
2665
2989
|
}
|
|
2990
|
+
// Already mid-removal — don't queue a duplicate.
|
|
2991
|
+
if (state.deletingTickets[di.issue.identifier]?.phase === "removing") {
|
|
2992
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Already removing this worktree" });
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2666
2995
|
dispatch({ type: "SET_OVERLAY", overlay: "confirm-delete" });
|
|
2667
2996
|
return;
|
|
2668
2997
|
}
|
|
2669
2998
|
}, {
|
|
2670
2999
|
isActive: state.overlay !== "context-input" &&
|
|
3000
|
+
// Triage ask: the input phase is owned by MultilineTextArea.
|
|
3001
|
+
(state.overlay !== "triage-ask" || state.triageAskPhase !== "input") &&
|
|
2671
3002
|
// Issue form title/description are owned by MultilineTextArea;
|
|
2672
3003
|
// only the "saving" phase needs the outer handler (a no-op
|
|
2673
3004
|
// swallow), so disabling it for the whole overlay is fine.
|
|
@@ -2685,10 +3016,47 @@ export default function Dashboard() {
|
|
|
2685
3016
|
// The active issue/tree row drives the detail pane and action row.
|
|
2686
3017
|
const selectedIssue = (state.activeTab === "trees"
|
|
2687
3018
|
? state.flatTrees[state.treeSelectedIndex]
|
|
2688
|
-
: state.
|
|
3019
|
+
: state.activeTab === "triage"
|
|
3020
|
+
? state.flatTriage[state.triageSelectedIndex]
|
|
3021
|
+
: state.flatIssues[state.selectedIndex]) ?? null;
|
|
2689
3022
|
const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
|
|
2690
3023
|
const activeTracker = getIssueTracker(repoRootRef.current);
|
|
2691
|
-
|
|
3024
|
+
// Comments for the selected triage issue (lazily loaded; undefined = still
|
|
3025
|
+
// fetching, which the detail panel renders as "loading…").
|
|
3026
|
+
const triageComments = state.activeTab === "triage" && selectedIssue
|
|
3027
|
+
? state.triageCommentsById[selectedIssue.issue.identifier]
|
|
3028
|
+
: undefined;
|
|
3029
|
+
// Compact on-call summary for the triage detail pane. Uses the first
|
|
3030
|
+
// schedule (sorted to put the viewer's own rotation first) and finds the
|
|
3031
|
+
// viewer's next upcoming shift.
|
|
3032
|
+
let onCall;
|
|
3033
|
+
if (state.activeTab === "triage" && state.triageSchedules.length > 0) {
|
|
3034
|
+
const sched = state.triageSchedules[0];
|
|
3035
|
+
const now = Date.now();
|
|
3036
|
+
const next = sched.shifts.find((s) => s.isMe && Date.parse(s.startsAt) > now);
|
|
3037
|
+
onCall = {
|
|
3038
|
+
currentName: sched.currentName,
|
|
3039
|
+
currentIsMe: sched.currentIsMe,
|
|
3040
|
+
myNext: next
|
|
3041
|
+
? new Date(next.startsAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
|
3042
|
+
: null,
|
|
3043
|
+
};
|
|
3044
|
+
}
|
|
3045
|
+
// Worktree deletions in flight — `deletingIds` marks list rows, the selected
|
|
3046
|
+
// one's status drives the detail-pane progress view.
|
|
3047
|
+
const deletingIds = new Set(Object.keys(state.deletingTickets));
|
|
3048
|
+
const selectedDeleteStatus = selectedIssue
|
|
3049
|
+
? state.deletingTickets[selectedIssue.issue.identifier]
|
|
3050
|
+
: undefined;
|
|
3051
|
+
return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "santree" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), updateAvailable && latestVersion ? (_jsxs(Text, { color: "yellow", children: [" ⬆ v", latestVersion, " available — `santree update`"] })) : null, CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" · claude ", CLAUDE_VERSION] })) : null, claudeUpdateAvailable && latestClaudeVersion ? (_jsxs(Text, { color: "yellow", children: [" ⬆ ", latestClaudeVersion] })) : null, state.refreshing ? (_jsxs(Text, { dimColor: true, children: [" · ", _jsx(Spinner, { type: "dots" }), " refreshing"] })) : null, state.actionMessage ? (_jsxs(Text, { color: "yellow", children: [" · ", state.actionMessage] })) : null] }), _jsx(Box, { paddingX: 1, children: tabOrder.map((tab, i) => {
|
|
3052
|
+
const meta = {
|
|
3053
|
+
triage: { name: "Triage", count: state.flatTriage.length },
|
|
3054
|
+
issues: { name: "Issues", count: state.flatIssues.length },
|
|
3055
|
+
trees: { name: "Trees", count: state.flatTrees.length },
|
|
3056
|
+
reviews: { name: "Reviews", count: state.flatReviews.length },
|
|
3057
|
+
}[tab];
|
|
3058
|
+
return (_jsxs(Box, { children: [i > 0 ? _jsx(Text, { children: " " }) : null, _jsx(Tab, { active: state.activeTab === tab, label: `${i + 1} ${meta.name} (${meta.count})`, mode: theme.mode })] }, tab));
|
|
3059
|
+
}) }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", children: [state.overlay === "help" ? (_jsx(HelpOverlay, { width: innerWidth, height: contentHeight })) : state.overlay === "tracker-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: state.trackerSelectPhase === "linear-org" ? (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Select a Linear workspace:" }), _jsx(Text, { children: " " }), state.trackerSelectOrgs.map((org, i) => {
|
|
2692
3060
|
const sel = i === state.trackerSelectIndex;
|
|
2693
3061
|
return (_jsxs(Text, { color: sel ? "cyan" : undefined, bold: sel, children: [sel ? "> " : " ", org.name, " (", org.slug, ")"] }, org.slug));
|
|
2694
3062
|
}), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to link, ESC to go back" })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Select an issue tracker for this repo:" }), _jsx(Text, { children: " " }), [
|
|
@@ -2715,15 +3083,32 @@ export default function Dashboard() {
|
|
|
2715
3083
|
const defaultBranch = getDefaultBranch();
|
|
2716
3084
|
const label = branch === defaultBranch ? `${branch} (default)` : branch;
|
|
2717
3085
|
return (_jsx(Text, { children: _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", label] }) }, branch));
|
|
2718
|
-
}), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to select, ESC to cancel" })] }) })) : state.overlay === "confirm-delete" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Remove worktree?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: selectedIssue?.worktree?.branch ?? "" }), selectedIssue?.worktree?.dirty && (_jsx(Text, { color: "yellow", children: "Warning: worktree has uncommitted changes" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : state.overlay === "diff" ? (_jsx(DiffOverlay, { width: innerWidth, height: contentHeight, ticketId: state.diffTicketId ?? "", baseBranch: state.diffBaseBranch ?? "", files: state.diffFiles, fileIndex: state.diffFileIndex, fileScrollOffset: state.diffFileScrollOffset, content: state.diffContent, contentScrollOffset: state.diffContentScrollOffset, loadingFiles: state.diffLoadingFiles, loadingContent: state.diffLoadingContent, error: state.diffError, selectionBg: theme.selectionBg, leftWidthOverride: diffLeftWidth ?? undefined, pendingDiscard: state.diffPendingDiscard })) : state.overlay === "confirm-setup" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Run setup script?" }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: ".santree/init.sh" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " Run setup"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", bold: true, children: "n" }), " Skip"] })] }) })) : (_jsxs(Box, {
|
|
3086
|
+
}), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to select, ESC to cancel" })] }) })) : state.overlay === "confirm-delete" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Remove worktree?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: selectedIssue?.worktree?.branch ?? "" }), selectedIssue?.worktree?.dirty && (_jsx(Text, { color: "yellow", children: "Warning: worktree has uncommitted changes" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : state.overlay === "diff" ? (_jsx(DiffOverlay, { width: innerWidth, height: contentHeight, ticketId: state.diffTicketId ?? "", baseBranch: state.diffBaseBranch ?? "", files: state.diffFiles, fileIndex: state.diffFileIndex, fileScrollOffset: state.diffFileScrollOffset, content: state.diffContent, contentScrollOffset: state.diffContentScrollOffset, loadingFiles: state.diffLoadingFiles, loadingContent: state.diffLoadingContent, error: state.diffError, selectionBg: theme.selectionBg, leftWidthOverride: diffLeftWidth ?? undefined, pendingDiscard: state.diffPendingDiscard })) : state.overlay === "confirm-setup" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Run setup script?" }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: ".santree/init.sh" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " Run setup"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", bold: true, children: "n" }), " Skip"] })] }) })) : state.overlay === "triage-schedule" ? (_jsx(TriageScheduleOverlay, { schedules: state.triageSchedules, scrollOffset: state.triageScheduleScrollOffset, width: innerWidth, height: contentHeight })) : state.overlay === "triage-ask" ? (_jsxs(Box, { flexDirection: "column", width: innerWidth, height: contentHeight, paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Ask about ", state.triageAskTicketId] }), state.triageAskPhase === "input" ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Claude answers from the issue + all its comments (read-only)." }), _jsx(Text, { children: " " }), _jsx(MultilineTextArea, { value: state.triageAskQuestion, onChange: (v) => dispatch({ type: "TRIAGE_ASK_CHANGE", value: v }), onSubmit: () => {
|
|
3087
|
+
const q = state.triageAskQuestion.trim();
|
|
3088
|
+
if (q)
|
|
3089
|
+
void doAsk(q);
|
|
3090
|
+
}, onCancel: () => dispatch({ type: "TRIAGE_ASK_CLOSE" }), width: innerWidth - 2, height: 6, placeholder: "e.g. Is this a real bug? Where would the fix go?" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " ask · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+G" }), " cancel"] })] })) : state.triageAskPhase === "running" ? (_jsx(Box, { flexGrow: 1, alignItems: "center", justifyContent: "center", children: _jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " Asking Claude\u2026"] }) })) : state.triageAskPhase === "error" ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "red", children: state.triageAskError }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "a" }), " ask again · ", _jsx(Text, { color: "cyan", bold: true, children: "q" }), " close"] })] })) : (_jsxs(_Fragment, { children: [state.triageAskQuestion ? (_jsx(Text, { dimColor: true, children: `Q: ${state.triageAskQuestion.replace(/\n+/g, " ")}`.slice(0, innerWidth - 2) })) : null, _jsx(Text, { children: " " }), (() => {
|
|
3091
|
+
const allLines = (state.triageAskAnswer ?? "").split("\n");
|
|
3092
|
+
const bodyHeight = Math.max(1, contentHeight - 5);
|
|
3093
|
+
const off = Math.min(state.triageAskScrollOffset, Math.max(0, allLines.length - bodyHeight));
|
|
3094
|
+
return allLines
|
|
3095
|
+
.slice(off, off + bodyHeight)
|
|
3096
|
+
.map((ln, i) => (_jsx(Text, { children: ln.length > innerWidth - 2 ? ln.slice(0, innerWidth - 3) + "…" : ln || " " }, i)));
|
|
3097
|
+
})(), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "\u21E7\u2191\u2193/j/k" }), " scroll · ", _jsx(Text, { color: "cyan", bold: true, children: "a" }), " ask again · ", _jsx(Text, { color: "cyan", bold: true, children: "q" }), " close"] })] }))] })) : (_jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: leftWidth, children: state.activeTab === "reviews" ? (_jsx(ReviewList, { flatReviews: state.flatReviews, selectedIndex: state.reviewSelectedIndex, scrollOffset: state.reviewListScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg })) : state.activeTab === "triage" ? (state.flatTriage.length === 0 ? (_jsx(Box, { width: leftWidth, height: contentHeight, justifyContent: "center", alignItems: "center", children: _jsx(Text, { color: "yellow", children: "Triage inbox empty" }) })) : (_jsx(IssueList, { groups: state.triageGroups, flatIssues: state.flatTriage, selectedIndex: state.triageSelectedIndex, scrollOffset: state.triageListScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg, variant: "triage" }))) : (state.activeTab === "trees" ? state.flatTrees : state.flatIssues).length ===
|
|
2719
3098
|
0 ? (_jsxs(Box, { width: leftWidth, height: contentHeight, justifyContent: "center", alignItems: "center", flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: state.activeTab === "trees" ? "No worktrees yet" : "No active issues" }), state.activeTab === "issues" && activeTracker.canMutate ? (_jsx(Text, { dimColor: true, children: "Press n to create one" })) : null] })) : (_jsx(IssueList, { groups: state.activeTab === "trees" ? state.treeGroups : state.groups, flatIssues: state.activeTab === "trees" ? state.flatTrees : state.flatIssues, selectedIndex: state.activeTab === "trees" ? state.treeSelectedIndex : state.selectedIndex, scrollOffset: state.activeTab === "trees"
|
|
2720
3099
|
? state.treeListScrollOffset
|
|
2721
|
-
: state.listScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg })) }), _jsx(Box, { flexDirection: "column", width: 3, children: Array.from({ length: contentHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: " │ " }, i))) }), _jsx(Box, { width: rightWidth, children: state.activeTab === "reviews" && state.creatingForTicket ? (_jsxs(Box, { flexDirection: "column", width: rightWidth, height: contentHeight, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Setting up worktree for ", state.creatingForTicket, "..."] }), state.creationLogs
|
|
3100
|
+
: state.listScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg, deletingIds: state.activeTab === "trees" ? deletingIds : undefined, variant: state.activeTab === "issues" ? "issues" : "default" })) }), _jsx(Box, { flexDirection: "column", width: 3, children: Array.from({ length: contentHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: " │ " }, i))) }), _jsx(Box, { width: rightWidth, children: state.activeTab === "reviews" && state.creatingForTicket ? (_jsxs(Box, { flexDirection: "column", width: rightWidth, height: contentHeight, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Setting up worktree for ", state.creatingForTicket, "..."] }), state.creationLogs
|
|
2722
3101
|
.split("\n")
|
|
2723
3102
|
.slice(-(contentHeight - 1))
|
|
2724
|
-
.map((line, i) => (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: line }) }, i)))] })) : state.activeTab === "reviews" ? (_jsx(ReviewDetailPanel, { item: selectedReview, scrollOffset: state.reviewDetailScrollOffset, height: contentHeight, width: rightWidth })) : state.overlay === "commit" ? (_jsx(CommitOverlay, { width: rightWidth, height: contentHeight, branch: state.commitBranch, ticketId: state.commitTicketId, gitStatus: state.commitGitStatus, phase: state.commitPhase, message: state.commitMessage, error: state.commitError, dispatch: dispatch, onSubmit: handleCommitSubmit })) : state.overlay === "pr-create" ? (_jsx(PrCreateOverlay, { width: rightWidth, height: contentHeight, branch: state.prCreateBranch, ticketId: state.prCreateTicketId, phase: state.prCreatePhase, error: state.prCreateError, url: state.prCreateUrl, body: state.prCreateBody, title: state.prCreateTitle, dispatch: dispatch })) :
|
|
3103
|
+
.map((line, i) => (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: line }) }, i)))] })) : state.activeTab === "reviews" ? (_jsx(ReviewDetailPanel, { item: selectedReview, scrollOffset: state.reviewDetailScrollOffset, height: contentHeight, width: rightWidth })) : state.overlay === "commit" ? (_jsx(CommitOverlay, { width: rightWidth, height: contentHeight, branch: state.commitBranch, ticketId: state.commitTicketId, gitStatus: state.commitGitStatus, phase: state.commitPhase, message: state.commitMessage, error: state.commitError, dispatch: dispatch, onSubmit: handleCommitSubmit })) : state.overlay === "pr-create" ? (_jsx(PrCreateOverlay, { width: rightWidth, height: contentHeight, branch: state.prCreateBranch, ticketId: state.prCreateTicketId, phase: state.prCreatePhase, error: state.prCreateError, url: state.prCreateUrl, body: state.prCreateBody, title: state.prCreateTitle, draft: state.prCreateDraft, dispatch: dispatch })) : state.activeTab === "triage" && !selectedIssue ? (
|
|
3104
|
+
// Empty (or nothing-selected) triage inbox: surface the
|
|
3105
|
+
// on-call rotation in the otherwise-idle right pane so it's
|
|
3106
|
+
// visible without selecting an issue or pressing [s].
|
|
3107
|
+
_jsx(TriageScheduleOverlay, { schedules: state.triageSchedules, scrollOffset: state.triageScheduleScrollOffset, width: rightWidth, height: contentHeight })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.activeTab === "trees"
|
|
2725
3108
|
? state.treeDetailScrollOffset
|
|
2726
|
-
: state.
|
|
3109
|
+
: state.activeTab === "triage"
|
|
3110
|
+
? 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 }) })] })) })] })] }));
|
|
2727
3112
|
}
|
|
2728
3113
|
/**
|
|
2729
3114
|
* Renders the per-issue action key hints (Resume / Editor / View diff / …)
|