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.
@@ -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 (≤30s). Skip the
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 isTreesTab = s.activeTab === "trees";
543
- const flat = isTreesTab ? s.flatTrees : s.flatIssues;
544
- const idx = isTreesTab ? s.treeSelectedIndex : s.selectedIndex;
545
- const detailOff = isTreesTab ? s.treeDetailScrollOffset : s.detailScrollOffset;
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: isTreesTab ? "TREE_SELECT" : "SELECT", index: next });
583
+ dispatch({ type: selectType, index: next });
552
584
  }
553
585
  else {
554
586
  const next = Math.max(0, detailOff + delta);
555
- dispatch({ type: isTreesTab ? "TREE_SCROLL_DETAIL" : "SCROLL_DETAIL", offset: next });
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 isTreesTab = s.activeTab === "trees";
630
- const flat = isTreesTab ? s.flatTrees : s.flatIssues;
631
- const grps = isTreesTab ? s.treeGroups : s.groups;
632
- const scrollOff = isTreesTab ? s.treeListScrollOffset : s.listScrollOffset;
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: isTreesTab ? "TREE_SELECT" : "SELECT", index: flatIdx });
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 30s. While the diff overlay is open, also bump
651
- // the diff refresh tick so new/removed files (created or deleted
652
- // outside the dashboard) eventually show up. Stage/unstage already
653
- // patch XY in place, so this is purely about file-set drift.
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
- }, 30_000);
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
- }, [state.overlay, state.issueFormPhase, state.prCreatePhase, state.commitPhase]);
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.flatIssues[sref.selectedIndex];
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.flatIssues[state.selectedIndex];
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 { stdout } = await execAsync(`gh pr create --title "${s.prCreateTitle.replace(/"/g, '\\"')}" --base "${base}" --head "${s.prCreateBranch}" --body-file "${bodyFile}"`, { cwd });
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
- await execAsync(`gh pr create --web --base "${base}" --head "${s.prCreateBranch}"`, { cwd });
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
- if (di?.worktree) {
1973
- const repoRoot = repoRootRef.current;
1974
- if (repoRoot) {
1975
- dispatch({ type: "DELETE_START", ticketId: di.issue.identifier });
1976
- const force = di.worktree.dirty;
1977
- removeWorktree(di.worktree.branch, repoRoot, force).then((result) => {
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
- const TAB_ORDER = ["issues", "trees", "reviews"];
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 = TAB_ORDER.indexOf(state.activeTab);
2082
- dispatch({ type: "SET_TAB", tab: TAB_ORDER[(idx + 1) % TAB_ORDER.length] });
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 === "2") {
2090
- dispatch({ type: "SET_TAB", tab: "trees" });
2091
- return;
2092
- }
2093
- if (input === "3") {
2094
- dispatch({ type: "SET_TAB", tab: "reviews" });
2095
- return;
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
- dispatch({ type: "DELETE_START", ticketId });
2334
- const force = ri.worktree.dirty;
2335
- removeWorktree(ri.branch, repoRoot, force).then((result) => {
2336
- dispatch({ type: "DELETE_DONE" });
2337
- if (result.success) {
2338
- dispatch({ type: "SET_ACTION_MESSAGE", message: `Removed worktree` });
2339
- refresh();
2340
- }
2341
- else {
2342
- dispatch({ type: "SET_ACTION_MESSAGE", message: `Failed: ${result.error}` });
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.flatIssues[state.selectedIndex]) ?? null;
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
- 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 ? _jsx(Text, { dimColor: true, children: " · refreshing…" }) : null, state.actionMessage ? (_jsxs(Text, { color: "yellow", children: [" · ", state.actionMessage] })) : null] }), _jsxs(Box, { paddingX: 1, children: [_jsx(Tab, { active: state.activeTab === "issues", label: `1 Issues (${state.flatIssues.length})`, mode: theme.mode }), _jsx(Text, { children: " " }), _jsx(Tab, { active: state.activeTab === "trees", label: `2 Trees (${state.flatTrees.length})`, mode: theme.mode }), _jsx(Text, { children: " " }), _jsx(Tab, { active: state.activeTab === "reviews", label: `3 Reviews (${state.flatReviews.length})`, mode: theme.mode })] }), _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) => {
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, { 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 === "trees" ? state.flatTrees : state.flatIssues).length ===
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 })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.activeTab === "trees"
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.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] })), _jsx(Box, { children: state.overlay === "diff" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "diff" }) })) : (_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 }) })] })) })] })] }));
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 / …)