santree 0.6.2 → 0.7.0

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.
Files changed (35) hide show
  1. package/README.md +7 -7
  2. package/dist/commands/dashboard.js +465 -54
  3. package/dist/commands/issue/setup.d.ts +2 -0
  4. package/dist/commands/issue/setup.js +108 -0
  5. package/dist/commands/issue/switch.d.ts +1 -0
  6. package/dist/commands/issue/switch.js +2 -2
  7. package/dist/commands/worktree/work.js +1 -1
  8. package/dist/lib/ai.js +4 -0
  9. package/dist/lib/dashboard/DetailPanel.d.ts +5 -2
  10. package/dist/lib/dashboard/DetailPanel.js +24 -3
  11. package/dist/lib/dashboard/IssueList.d.ts +2 -0
  12. package/dist/lib/dashboard/IssueList.js +9 -1
  13. package/dist/lib/dashboard/Overlays.d.ts +2 -1
  14. package/dist/lib/dashboard/Overlays.js +17 -3
  15. package/dist/lib/dashboard/data.d.ts +2 -0
  16. package/dist/lib/dashboard/data.js +56 -54
  17. package/dist/lib/dashboard/types.d.ts +80 -2
  18. package/dist/lib/dashboard/types.js +97 -1
  19. package/dist/lib/multiplexer/cmux.js +0 -15
  20. package/dist/lib/multiplexer/none.js +0 -3
  21. package/dist/lib/multiplexer/tmux.js +0 -8
  22. package/dist/lib/multiplexer/types.d.ts +0 -1
  23. package/dist/lib/session-signal.d.ts +5 -3
  24. package/dist/lib/session-signal.js +5 -22
  25. package/dist/lib/trackers/config.js +1 -1
  26. package/dist/lib/trackers/index.d.ts +11 -0
  27. package/dist/lib/trackers/index.js +26 -0
  28. package/dist/lib/trackers/local/frontmatter.d.ts +12 -0
  29. package/dist/lib/trackers/local/frontmatter.js +91 -0
  30. package/dist/lib/trackers/local/index.d.ts +2 -0
  31. package/dist/lib/trackers/local/index.js +102 -0
  32. package/dist/lib/trackers/local/store.d.ts +30 -0
  33. package/dist/lib/trackers/local/store.js +203 -0
  34. package/dist/lib/trackers/types.d.ts +26 -1
  35. package/package.json +1 -1
@@ -15,10 +15,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";
21
- import { getIssueTracker } from "../lib/trackers/index.js";
22
+ import { getIssueTracker, isRepoTrackerConfigured, setRepoTracker } from "../lib/trackers/index.js";
23
+ import { setRepoLinearOrg } from "../lib/trackers/linear/index.js";
24
+ import { readLinearAuthStore } from "../lib/trackers/auth-store.js";
25
+ import { getAuthenticatedUser, getCurrentRepoNwo } from "../lib/trackers/github/auth.js";
22
26
  import { openUrl } from "../lib/open-url.js";
23
27
  import { parseUnifiedDiff } from "../lib/diff-parse.js";
24
28
  import * as os from "os";
@@ -264,7 +268,6 @@ function ensureAltScreen() {
264
268
  if (altScreenEntered)
265
269
  return;
266
270
  altScreenEntered = true;
267
- getMultiplexer().renameWindow("", "santree");
268
271
  process.stdout.write("\x1b[?1049h"); // Enter alternate screen buffer
269
272
  process.stdout.write("\x1b[?25l"); // Hide cursor
270
273
  }
@@ -387,9 +390,18 @@ export default function Dashboard() {
387
390
  return;
388
391
  }
389
392
  repoRootRef.current = repoRoot;
393
+ // No tracker configured → show the selection flow instead of letting
394
+ // getIssueTracker silently fall back to GitHub and then fail auth on
395
+ // the dead-end error screen. Genuine auth/network failures of a
396
+ // *configured* tracker still hit the catch → error screen below.
397
+ if (!isRepoTrackerConfigured(repoRoot)) {
398
+ dispatch({ type: "TRACKER_SELECT_OPEN" });
399
+ return;
400
+ }
390
401
  try {
391
402
  // Re-detect terminal theme alongside data fetch so light↔dark
392
- // switches propagate within one refresh cycle (≤30s). Skip the
403
+ // switches propagate within one refresh cycle (≤5min, or sooner
404
+ // on a manual `R`). Skip the
393
405
  // OSC 11 query when a text-input overlay is active — the
394
406
  // terminal's response would otherwise leak into the user's
395
407
  // commit/PR/context message via Ink's stdin handler.
@@ -528,18 +540,22 @@ export default function Dashboard() {
528
540
  }
529
541
  return;
530
542
  }
531
- if (inLeftPane) {
532
- // Scroll left pane (issue list)
533
- const maxIdx = s.flatIssues.length - 1;
534
- if (maxIdx < 0)
535
- return;
536
- const next = Math.max(0, Math.min(s.selectedIndex + delta, maxIdx));
537
- dispatch({ type: "SELECT", index: next });
538
- }
539
- else {
540
- // Scroll right pane (detail)
541
- const next = Math.max(0, s.detailScrollOffset + delta);
542
- dispatch({ type: "SCROLL_DETAIL", offset: next });
543
+ {
544
+ const isTreesTab = s.activeTab === "trees";
545
+ const flat = isTreesTab ? s.flatTrees : s.flatIssues;
546
+ const idx = isTreesTab ? s.treeSelectedIndex : s.selectedIndex;
547
+ const detailOff = isTreesTab ? s.treeDetailScrollOffset : s.detailScrollOffset;
548
+ if (inLeftPane) {
549
+ const maxIdx = flat.length - 1;
550
+ if (maxIdx < 0)
551
+ return;
552
+ const next = Math.max(0, Math.min(idx + delta, maxIdx));
553
+ dispatch({ type: isTreesTab ? "TREE_SELECT" : "SELECT", index: next });
554
+ }
555
+ else {
556
+ const next = Math.max(0, detailOff + delta);
557
+ dispatch({ type: isTreesTab ? "TREE_SCROLL_DETAIL" : "SCROLL_DETAIL", offset: next });
558
+ }
543
559
  }
544
560
  return;
545
561
  }
@@ -611,12 +627,18 @@ export default function Dashboard() {
611
627
  }
612
628
  return;
613
629
  }
614
- if (s.flatIssues.length === 0)
615
- return;
616
- const listRow = s.listScrollOffset + contentRow;
617
- const flatIdx = getFlatIndexForListRow(s.groups, s.flatIssues, listRow);
618
- if (flatIdx !== null && flatIdx >= 0 && flatIdx < s.flatIssues.length) {
619
- dispatch({ type: "SELECT", index: flatIdx });
630
+ {
631
+ const isTreesTab = s.activeTab === "trees";
632
+ const flat = isTreesTab ? s.flatTrees : s.flatIssues;
633
+ const grps = isTreesTab ? s.treeGroups : s.groups;
634
+ const scrollOff = isTreesTab ? s.treeListScrollOffset : s.listScrollOffset;
635
+ if (flat.length === 0)
636
+ return;
637
+ const listRow = scrollOff + contentRow;
638
+ const flatIdx = getFlatIndexForListRow(grps, flat, listRow);
639
+ if (flatIdx !== null && flatIdx >= 0 && flatIdx < flat.length) {
640
+ dispatch({ type: isTreesTab ? "TREE_SELECT" : "SELECT", index: flatIdx });
641
+ }
620
642
  }
621
643
  };
622
644
  if (process.stdin.isTTY) {
@@ -627,16 +649,21 @@ export default function Dashboard() {
627
649
  await refresh(true);
628
650
  };
629
651
  init();
630
- // Auto-refresh every 30s. While the diff overlay is open, also bump
631
- // the diff refresh tick so new/removed files (created or deleted
632
- // outside the dashboard) eventually show up. Stage/unstage already
633
- // patch XY in place, so this is purely about file-set drift.
652
+ // Auto-refresh every 5 minutes. Each refresh fans out into several
653
+ // `gh pr view`/`gh pr checks` calls per worktree-PR plus the reviews
654
+ // tab, all on the GraphQL API (5000-point/hour budget) a 30s cadence
655
+ // drained it within the hour when the dashboard was left open. Press
656
+ // `R` for an on-demand refresh between cycles. While the diff overlay
657
+ // is open, also bump the diff refresh tick so new/removed files
658
+ // (created or deleted outside the dashboard) eventually show up.
659
+ // Stage/unstage already patch XY in place, so this is purely about
660
+ // file-set drift.
634
661
  refreshTimerRef.current = setInterval(() => {
635
662
  refresh();
636
663
  if (stateRef.current.overlay === "diff") {
637
664
  dispatch({ type: "DIFF_REFRESH_FILES" });
638
665
  }
639
- }, 30_000);
666
+ }, 300_000);
640
667
  return () => {
641
668
  if (refreshTimerRef.current)
642
669
  clearInterval(refreshTimerRef.current);
@@ -663,6 +690,21 @@ export default function Dashboard() {
663
690
  dispatch({ type: "SCROLL_LIST", offset });
664
691
  }
665
692
  }, [state.selectedIndex, state.groups, contentHeight, state.listScrollOffset]);
693
+ // ── Trees list scroll tracking ───────────────────────────────────
694
+ useEffect(() => {
695
+ const rowIdx = getRowIndexForFlatIndex(state.treeGroups, state.flatTrees, state.treeSelectedIndex);
696
+ const maxVisible = contentHeight - LIST_FOOTER_HEIGHT;
697
+ let offset = state.treeListScrollOffset;
698
+ if (rowIdx < offset) {
699
+ offset = Math.max(0, rowIdx - 1);
700
+ }
701
+ else if (rowIdx >= offset + maxVisible) {
702
+ offset = rowIdx - maxVisible + 2;
703
+ }
704
+ if (offset !== state.treeListScrollOffset) {
705
+ dispatch({ type: "TREE_SCROLL_LIST", offset });
706
+ }
707
+ }, [state.treeSelectedIndex, state.treeGroups, contentHeight, state.treeListScrollOffset]);
666
708
  // ── Review list scroll tracking ──────────────────────────────────
667
709
  useEffect(() => {
668
710
  // Row index = 1 (column header) + flatIndex
@@ -686,6 +728,7 @@ export default function Dashboard() {
686
728
  // Disable tracking while any text-input overlay is mounted; restore on exit.
687
729
  useEffect(() => {
688
730
  const needsMouseOff = state.overlay === "context-input" ||
731
+ (state.overlay === "issue-form" && state.issueFormPhase !== "saving") ||
689
732
  (state.overlay === "pr-create" && state.prCreatePhase === "review") ||
690
733
  (state.overlay === "commit" && state.commitPhase === "awaiting-message");
691
734
  if (!needsMouseOff)
@@ -694,7 +737,7 @@ export default function Dashboard() {
694
737
  return () => {
695
738
  process.stdout.write("\x1b[?1002h\x1b[?1006h");
696
739
  };
697
- }, [state.overlay, state.prCreatePhase, state.commitPhase]);
740
+ }, [state.overlay, state.issueFormPhase, state.prCreatePhase, state.commitPhase]);
698
741
  // ── Diff overlay: load file list when opened (gh pr diff path) ────
699
742
  // Reviews-tab PRs without a local worktree shell out to `gh pr diff <n>`,
700
743
  // parse the unified blob into per-file records, and stash the per-file
@@ -982,7 +1025,10 @@ export default function Dashboard() {
982
1025
  }
983
1026
  }, []);
984
1027
  const createAndLaunch = useCallback(async (mode, runSetup, base, contextFile) => {
985
- const di = stateRef.current.flatIssues[stateRef.current.selectedIndex];
1028
+ const sref = stateRef.current;
1029
+ const di = sref.activeTab === "trees"
1030
+ ? sref.flatTrees[sref.treeSelectedIndex]
1031
+ : sref.flatIssues[sref.selectedIndex];
986
1032
  if (!di)
987
1033
  return;
988
1034
  const repoRoot = repoRootRef.current;
@@ -1091,7 +1137,9 @@ export default function Dashboard() {
1091
1137
  createAndLaunch(mode, false, base, contextFile);
1092
1138
  }, [createAndLaunch]);
1093
1139
  const doWork = useCallback((mode, customContext) => {
1094
- const di = state.flatIssues[state.selectedIndex];
1140
+ const di = state.activeTab === "trees"
1141
+ ? state.flatTrees[state.treeSelectedIndex]
1142
+ : state.flatIssues[state.selectedIndex];
1095
1143
  if (!di)
1096
1144
  return;
1097
1145
  const repoRoot = repoRootRef.current;
@@ -1118,7 +1166,9 @@ export default function Dashboard() {
1118
1166
  pendingContextFileRef.current = contextFile ?? null;
1119
1167
  const defaultBranch = getDefaultBranch();
1120
1168
  const baseOptions = [defaultBranch];
1121
- for (const fi of state.flatIssues) {
1169
+ // Worktree branches live on the Trees tab now; scan there for
1170
+ // candidate base branches (stacked work).
1171
+ for (const fi of [...state.flatTrees, ...state.flatIssues]) {
1122
1172
  if (fi.worktree && !baseOptions.includes(fi.worktree.branch)) {
1123
1173
  baseOptions.push(fi.worktree.branch);
1124
1174
  }
@@ -1136,11 +1186,153 @@ export default function Dashboard() {
1136
1186
  }, [
1137
1187
  state.flatIssues,
1138
1188
  state.selectedIndex,
1189
+ state.flatTrees,
1190
+ state.treeSelectedIndex,
1191
+ state.activeTab,
1139
1192
  exit,
1140
1193
  launchWorkInTmux,
1141
1194
  proceedAfterBaseSelect,
1142
1195
  writeContextFile,
1143
1196
  ]);
1197
+ // ── Tracker selection ────────────────────────────────────────────
1198
+ // Mirrors `santree issue setup`. Local needs no account; Linear picks an
1199
+ // authenticated workspace (sub-list when >1); GitHub verifies `gh`.
1200
+ const chooseTracker = useCallback(async (kind) => {
1201
+ const root = repoRootRef.current ?? findMainRepoRoot();
1202
+ if (!root) {
1203
+ dispatch({ type: "SET_ERROR", error: "Not inside a git repository" });
1204
+ return;
1205
+ }
1206
+ repoRootRef.current = root;
1207
+ if (kind === "local") {
1208
+ setRepoTracker(root, "local");
1209
+ dispatch({ type: "TRACKER_SELECT_CLOSE" });
1210
+ refresh();
1211
+ return;
1212
+ }
1213
+ if (kind === "linear") {
1214
+ const store = readLinearAuthStore();
1215
+ const orgs = Object.entries(store).map(([slug, tokens]) => ({
1216
+ slug,
1217
+ name: tokens.org_name,
1218
+ }));
1219
+ if (orgs.length === 0) {
1220
+ dispatch({
1221
+ type: "TRACKER_SELECT_MESSAGE",
1222
+ message: "No authenticated Linear workspaces. Run: santree linear auth",
1223
+ });
1224
+ return;
1225
+ }
1226
+ if (orgs.length === 1) {
1227
+ setRepoLinearOrg(root, orgs[0].slug);
1228
+ setRepoTracker(root, "linear");
1229
+ dispatch({ type: "TRACKER_SELECT_CLOSE" });
1230
+ refresh();
1231
+ return;
1232
+ }
1233
+ dispatch({ type: "TRACKER_SELECT_PHASE", phase: "linear-org", orgs });
1234
+ return;
1235
+ }
1236
+ // github
1237
+ const user = await getAuthenticatedUser();
1238
+ if (!user) {
1239
+ dispatch({
1240
+ type: "TRACKER_SELECT_MESSAGE",
1241
+ message: "GitHub CLI not authenticated. Run: santree github auth",
1242
+ });
1243
+ return;
1244
+ }
1245
+ setRepoTracker(root, "github");
1246
+ await getCurrentRepoNwo(root);
1247
+ dispatch({ type: "TRACKER_SELECT_CLOSE" });
1248
+ refresh();
1249
+ }, [refresh]);
1250
+ const chooseLinearOrg = useCallback((slug) => {
1251
+ const root = repoRootRef.current ?? findMainRepoRoot();
1252
+ if (!root)
1253
+ return;
1254
+ setRepoLinearOrg(root, slug);
1255
+ setRepoTracker(root, "linear");
1256
+ dispatch({ type: "TRACKER_SELECT_CLOSE" });
1257
+ refresh();
1258
+ }, [refresh]);
1259
+ // ── Issue CRUD (built-in tracker only) ───────────────────────────
1260
+ const submitIssueForm = useCallback(async () => {
1261
+ const s = stateRef.current;
1262
+ const root = repoRootRef.current;
1263
+ if (!root)
1264
+ return;
1265
+ const tracker = getIssueTracker(root);
1266
+ const title = s.issueFormTitle.split("\n")[0]?.trim() ?? "";
1267
+ if (!title) {
1268
+ dispatch({ type: "ISSUE_FORM_ERROR", error: "Title is required" });
1269
+ return;
1270
+ }
1271
+ const description = s.issueFormDescription;
1272
+ dispatch({ type: "ISSUE_FORM_PHASE", phase: "saving" });
1273
+ try {
1274
+ if (s.issueFormMode === "edit" && s.issueFormId && tracker.updateIssue) {
1275
+ const res = await tracker.updateIssue(s.issueFormId, { title, description }, root);
1276
+ if (!res.ok) {
1277
+ dispatch({ type: "ISSUE_FORM_ERROR", error: res.message ?? "Update failed" });
1278
+ return;
1279
+ }
1280
+ }
1281
+ else if (tracker.createIssue) {
1282
+ const res = await tracker.createIssue({ title, description }, root);
1283
+ if (!res.ok) {
1284
+ dispatch({ type: "ISSUE_FORM_ERROR", error: res.message ?? "Create failed" });
1285
+ return;
1286
+ }
1287
+ }
1288
+ else {
1289
+ dispatch({ type: "ISSUE_FORM_ERROR", error: "Tracker does not support editing" });
1290
+ return;
1291
+ }
1292
+ dispatch({ type: "ISSUE_FORM_CLOSE" });
1293
+ dispatch({
1294
+ type: "SET_ACTION_MESSAGE",
1295
+ message: s.issueFormMode === "edit" ? "Issue updated" : "Issue created",
1296
+ });
1297
+ refresh();
1298
+ }
1299
+ catch (e) {
1300
+ dispatch({
1301
+ type: "ISSUE_FORM_ERROR",
1302
+ error: e instanceof Error ? e.message : "Failed to save issue",
1303
+ });
1304
+ }
1305
+ }, [refresh]);
1306
+ const deleteSelectedIssue = useCallback(async () => {
1307
+ const s = stateRef.current;
1308
+ const root = repoRootRef.current;
1309
+ if (!root)
1310
+ return;
1311
+ const di = s.flatIssues[s.selectedIndex];
1312
+ if (!di)
1313
+ return;
1314
+ const tracker = getIssueTracker(root);
1315
+ dispatch({ type: "ISSUE_DELETE_CLOSE" });
1316
+ if (!tracker.deleteIssue)
1317
+ return;
1318
+ try {
1319
+ const res = await tracker.deleteIssue(di.issue.identifier, root);
1320
+ dispatch({
1321
+ type: "SET_ACTION_MESSAGE",
1322
+ message: res.ok
1323
+ ? `Deleted ${di.issue.identifier}`
1324
+ : `Failed: ${res.message ?? "delete failed"}`,
1325
+ });
1326
+ if (res.ok)
1327
+ refresh();
1328
+ }
1329
+ catch (e) {
1330
+ dispatch({
1331
+ type: "SET_ACTION_MESSAGE",
1332
+ message: e instanceof Error ? e.message : "Delete failed",
1333
+ });
1334
+ }
1335
+ }, [refresh]);
1144
1336
  // ── Commit flow ──────────────────────────────────────────────────
1145
1337
  const handleStageAll = useCallback(async () => {
1146
1338
  const wtPath = stateRef.current.commitWorktreePath;
@@ -1382,7 +1574,8 @@ export default function Dashboard() {
1382
1574
  try {
1383
1575
  const bodyFile = path.join(os.tmpdir(), `santree-pr-${Date.now()}.md`);
1384
1576
  fs.writeFileSync(bodyFile, s.prCreateBody);
1385
- const { stdout } = await execAsync(`gh pr create --title "${s.prCreateTitle.replace(/"/g, '\\"')}" --base "${base}" --head "${s.prCreateBranch}" --body-file "${bodyFile}"`, { cwd });
1577
+ const draftFlag = s.prCreateDraft ? " --draft" : "";
1578
+ const { stdout } = await execAsync(`gh pr create --title "${s.prCreateTitle.replace(/"/g, '\\"')}" --base "${base}" --head "${s.prCreateBranch}" --body-file "${bodyFile}"${draftFlag}`, { cwd });
1386
1579
  try {
1387
1580
  fs.unlinkSync(bodyFile);
1388
1581
  }
@@ -1404,8 +1597,25 @@ export default function Dashboard() {
1404
1597
  return;
1405
1598
  const base = getBaseBranch(s.prCreateBranch);
1406
1599
  const cwd = s.prCreateWorktreePath;
1600
+ // Carry the edited title/body into GitHub's compose page so the browser
1601
+ // opens pre-filled (gh passes them as URL query params). Without this,
1602
+ // the fill→"open in browser" path would drop everything the user just
1603
+ // reviewed. Note: very long bodies can be truncated by GitHub's URL
1604
+ // length limit — gh's documented `--web` behavior; the editable compose
1605
+ // page is the fallback. Draft selection lives in the browser dropdown,
1606
+ // since `gh --web` doesn't accept `--draft`.
1607
+ let bodyFile = null;
1407
1608
  try {
1408
- await execAsync(`gh pr create --web --base "${base}" --head "${s.prCreateBranch}"`, { cwd });
1609
+ let cmd = `gh pr create --web --base "${base}" --head "${s.prCreateBranch}"`;
1610
+ if (s.prCreateTitle) {
1611
+ cmd += ` --title "${s.prCreateTitle.replace(/"/g, '\\"')}"`;
1612
+ }
1613
+ if (s.prCreateBody) {
1614
+ bodyFile = path.join(os.tmpdir(), `santree-pr-${Date.now()}.md`);
1615
+ fs.writeFileSync(bodyFile, s.prCreateBody);
1616
+ cmd += ` --body-file "${bodyFile}"`;
1617
+ }
1618
+ await execAsync(cmd, { cwd });
1409
1619
  dispatch({ type: "PR_CREATE_DONE", url: "" });
1410
1620
  setTimeout(() => {
1411
1621
  dispatch({ type: "PR_CREATE_CANCEL" });
@@ -1416,6 +1626,14 @@ export default function Dashboard() {
1416
1626
  const msg = e?.stderr?.trim() || e?.message || "Failed to open in browser";
1417
1627
  dispatch({ type: "PR_CREATE_ERROR", error: msg });
1418
1628
  }
1629
+ finally {
1630
+ if (bodyFile) {
1631
+ try {
1632
+ fs.unlinkSync(bodyFile);
1633
+ }
1634
+ catch { }
1635
+ }
1636
+ }
1419
1637
  }, [refresh]);
1420
1638
  // ── Keyboard ──────────────────────────────────────────────────────
1421
1639
  useInput((input, key) => {
@@ -1497,6 +1715,10 @@ export default function Dashboard() {
1497
1715
  confirmPrCreate();
1498
1716
  return;
1499
1717
  }
1718
+ if (input === "d") {
1719
+ dispatch({ type: "PR_CREATE_TOGGLE_DRAFT" });
1720
+ return;
1721
+ }
1500
1722
  if (input === "e") {
1501
1723
  dispatch({ type: "PR_CREATE_EDIT" });
1502
1724
  return;
@@ -1782,7 +2004,8 @@ export default function Dashboard() {
1782
2004
  if (state.overlay === "confirm-delete") {
1783
2005
  if (input === "y") {
1784
2006
  dispatch({ type: "SET_OVERLAY", overlay: null });
1785
- const di = state.flatIssues[state.selectedIndex];
2007
+ // Worktree removal is a Trees-tab action.
2008
+ const di = state.flatTrees[state.treeSelectedIndex];
1786
2009
  if (di?.worktree) {
1787
2010
  const repoRoot = repoRootRef.current;
1788
2011
  if (repoRoot) {
@@ -1814,12 +2037,86 @@ export default function Dashboard() {
1814
2037
  }
1815
2038
  return;
1816
2039
  }
2040
+ // Tracker-selection overlay (shown when no tracker is configured,
2041
+ // or reopened with `t`). Not a text input — outer useInput drives it.
2042
+ if (state.overlay === "tracker-select") {
2043
+ if (state.trackerSelectPhase === "linear-org") {
2044
+ const orgs = state.trackerSelectOrgs;
2045
+ if (input === "j" || key.downArrow) {
2046
+ dispatch({
2047
+ type: "TRACKER_SELECT_MOVE",
2048
+ index: Math.min(state.trackerSelectIndex + 1, orgs.length - 1),
2049
+ });
2050
+ return;
2051
+ }
2052
+ if (input === "k" || key.upArrow) {
2053
+ dispatch({
2054
+ type: "TRACKER_SELECT_MOVE",
2055
+ index: Math.max(state.trackerSelectIndex - 1, 0),
2056
+ });
2057
+ return;
2058
+ }
2059
+ if (key.return) {
2060
+ const org = orgs[state.trackerSelectIndex];
2061
+ if (org)
2062
+ chooseLinearOrg(org.slug);
2063
+ return;
2064
+ }
2065
+ if (key.escape) {
2066
+ dispatch({ type: "TRACKER_SELECT_PHASE", phase: "root" });
2067
+ return;
2068
+ }
2069
+ return;
2070
+ }
2071
+ const TRACKER_KINDS = ["local", "linear", "github"];
2072
+ if (input === "j" || key.downArrow) {
2073
+ dispatch({
2074
+ type: "TRACKER_SELECT_MOVE",
2075
+ index: Math.min(state.trackerSelectIndex + 1, TRACKER_KINDS.length - 1),
2076
+ });
2077
+ return;
2078
+ }
2079
+ if (input === "k" || key.upArrow) {
2080
+ dispatch({
2081
+ type: "TRACKER_SELECT_MOVE",
2082
+ index: Math.max(state.trackerSelectIndex - 1, 0),
2083
+ });
2084
+ return;
2085
+ }
2086
+ if (key.return) {
2087
+ void chooseTracker(TRACKER_KINDS[state.trackerSelectIndex] ?? "local");
2088
+ return;
2089
+ }
2090
+ if (input === "q" || key.escape) {
2091
+ // Can't proceed without a tracker — leave the dashboard.
2092
+ exit();
2093
+ return;
2094
+ }
2095
+ return;
2096
+ }
2097
+ // Confirm-delete-issue overlay (built-in tracker only)
2098
+ if (state.overlay === "confirm-delete-issue") {
2099
+ if (input === "y") {
2100
+ void deleteSelectedIssue();
2101
+ return;
2102
+ }
2103
+ if (input === "n" || key.escape || input === "q") {
2104
+ dispatch({ type: "ISSUE_DELETE_CLOSE" });
2105
+ return;
2106
+ }
2107
+ return;
2108
+ }
2109
+ // Issue create/edit form. Title/description phases are owned by
2110
+ // MultilineTextArea (outer useInput disabled via isActive); only
2111
+ // the "saving" phase reaches here — swallow all keys.
2112
+ if (state.overlay === "issue-form") {
2113
+ return;
2114
+ }
1817
2115
  // Tab switching (only when no overlay is active)
2116
+ const TAB_ORDER = ["issues", "trees", "reviews"];
1818
2117
  if (key.tab) {
1819
- dispatch({
1820
- type: "SET_TAB",
1821
- tab: state.activeTab === "issues" ? "reviews" : "issues",
1822
- });
2118
+ const idx = TAB_ORDER.indexOf(state.activeTab);
2119
+ dispatch({ type: "SET_TAB", tab: TAB_ORDER[(idx + 1) % TAB_ORDER.length] });
1823
2120
  return;
1824
2121
  }
1825
2122
  if (input === "1") {
@@ -1827,9 +2124,18 @@ export default function Dashboard() {
1827
2124
  return;
1828
2125
  }
1829
2126
  if (input === "2") {
2127
+ dispatch({ type: "SET_TAB", tab: "trees" });
2128
+ return;
2129
+ }
2130
+ if (input === "3") {
1830
2131
  dispatch({ type: "SET_TAB", tab: "reviews" });
1831
2132
  return;
1832
2133
  }
2134
+ // Reopen tracker selection from any tab.
2135
+ if (input === "t") {
2136
+ dispatch({ type: "TRACKER_SELECT_OPEN" });
2137
+ return;
2138
+ }
1833
2139
  // Quit
1834
2140
  if (input === "q") {
1835
2141
  exit();
@@ -2077,33 +2383,109 @@ export default function Dashboard() {
2077
2383
  }
2078
2384
  return; // prevent fallthrough to issues actions
2079
2385
  }
2080
- const maxIndex = state.flatIssues.length - 1;
2386
+ // Issues tab = backlog/planning; Trees tab = worktrees in
2387
+ // progress. Both reuse the same list/detail UI and worktree action
2388
+ // handlers below — only the backing data + selection slice differ.
2389
+ const isTrees = state.activeTab === "trees";
2390
+ const viewFlat = isTrees ? state.flatTrees : state.flatIssues;
2391
+ const viewIndex = isTrees ? state.treeSelectedIndex : state.selectedIndex;
2392
+ const viewDetailScroll = isTrees ? state.treeDetailScrollOffset : state.detailScrollOffset;
2393
+ const selectAction = isTrees ? "TREE_SELECT" : "SELECT";
2394
+ const scrollDetailAction = isTrees
2395
+ ? "TREE_SCROLL_DETAIL"
2396
+ : "SCROLL_DETAIL";
2397
+ const maxIndex = viewFlat.length - 1;
2081
2398
  // Navigation
2082
2399
  if (input === "j" || (key.downArrow && !key.shift)) {
2083
- const next = Math.min(state.selectedIndex + 1, maxIndex);
2084
- dispatch({ type: "SELECT", index: next });
2400
+ dispatch({ type: selectAction, index: Math.min(viewIndex + 1, maxIndex) });
2085
2401
  return;
2086
2402
  }
2087
2403
  if (input === "k" || (key.upArrow && !key.shift)) {
2088
- const prev = Math.max(state.selectedIndex - 1, 0);
2089
- dispatch({ type: "SELECT", index: prev });
2404
+ dispatch({ type: selectAction, index: Math.max(viewIndex - 1, 0) });
2090
2405
  return;
2091
2406
  }
2092
2407
  // Detail scroll
2093
2408
  if (key.shift && key.downArrow) {
2094
- dispatch({ type: "SCROLL_DETAIL", offset: state.detailScrollOffset + 3 });
2409
+ dispatch({ type: scrollDetailAction, offset: viewDetailScroll + 3 });
2095
2410
  return;
2096
2411
  }
2097
2412
  if (key.shift && key.upArrow) {
2098
- dispatch({
2099
- type: "SCROLL_DETAIL",
2100
- offset: Math.max(0, state.detailScrollOffset - 3),
2101
- });
2413
+ dispatch({ type: scrollDetailAction, offset: Math.max(0, viewDetailScroll - 3) });
2102
2414
  return;
2103
2415
  }
2104
- const di = state.flatIssues[state.selectedIndex];
2416
+ const di = viewFlat[viewIndex];
2105
2417
  if (!di)
2106
2418
  return;
2419
+ // ── Issues tab: backlog actions only (no worktree ops) ──────
2420
+ if (!isTrees) {
2421
+ const tracker = getIssueTracker(repoRootRef.current);
2422
+ const canMutate = tracker.canMutate === true;
2423
+ if (input === "w") {
2424
+ if (di.worktree?.sessionId) {
2425
+ dispatch({
2426
+ type: "SET_ACTION_MESSAGE",
2427
+ message: "Session active — switch to the Trees tab to resume.",
2428
+ });
2429
+ return;
2430
+ }
2431
+ dispatch({ type: "SET_OVERLAY", overlay: "mode-select" });
2432
+ return;
2433
+ }
2434
+ if (input === "n") {
2435
+ if (!canMutate) {
2436
+ dispatch({
2437
+ type: "SET_ACTION_MESSAGE",
2438
+ message: `${tracker.displayName} issues can't be created from santree`,
2439
+ });
2440
+ return;
2441
+ }
2442
+ dispatch({
2443
+ type: "ISSUE_FORM_OPEN",
2444
+ mode: "create",
2445
+ id: null,
2446
+ title: "",
2447
+ description: "",
2448
+ });
2449
+ return;
2450
+ }
2451
+ if (input === "e") {
2452
+ if (!canMutate) {
2453
+ dispatch({
2454
+ type: "SET_ACTION_MESSAGE",
2455
+ message: `${tracker.displayName} issues can't be edited from santree`,
2456
+ });
2457
+ return;
2458
+ }
2459
+ dispatch({
2460
+ type: "ISSUE_FORM_OPEN",
2461
+ mode: "edit",
2462
+ id: di.issue.identifier,
2463
+ title: di.issue.title,
2464
+ description: di.issue.description ?? "",
2465
+ });
2466
+ return;
2467
+ }
2468
+ if (input === "d") {
2469
+ if (!canMutate) {
2470
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Nothing to delete here" });
2471
+ return;
2472
+ }
2473
+ dispatch({ type: "ISSUE_DELETE_OPEN" });
2474
+ return;
2475
+ }
2476
+ if (input === "o") {
2477
+ if (!di.issue.url) {
2478
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No issue URL available" });
2479
+ return;
2480
+ }
2481
+ if (openUrl(di.issue.url)) {
2482
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened in browser" });
2483
+ }
2484
+ return;
2485
+ }
2486
+ return; // backlog has no worktree/PR actions
2487
+ }
2488
+ // ── Trees tab: worktree-in-progress actions ─────────────────
2107
2489
  // Work
2108
2490
  if (input === "w") {
2109
2491
  if (di.worktree?.sessionId) {
@@ -2323,6 +2705,10 @@ export default function Dashboard() {
2323
2705
  }
2324
2706
  }, {
2325
2707
  isActive: state.overlay !== "context-input" &&
2708
+ // Issue form title/description are owned by MultilineTextArea;
2709
+ // only the "saving" phase needs the outer handler (a no-op
2710
+ // swallow), so disabling it for the whole overlay is fine.
2711
+ state.overlay !== "issue-form" &&
2326
2712
  (state.overlay !== "pr-create" || state.prCreatePhase !== "review") &&
2327
2713
  (state.overlay !== "commit" || state.commitPhase !== "awaiting-message"),
2328
2714
  });
@@ -2333,9 +2719,29 @@ export default function Dashboard() {
2333
2719
  if (state.error) {
2334
2720
  return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", state.error] }), _jsx(Text, { dimColor: true, children: "Press R to retry or q to quit" })] }) }));
2335
2721
  }
2336
- const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
2722
+ // The active issue/tree row drives the detail pane and action row.
2723
+ const selectedIssue = (state.activeTab === "trees"
2724
+ ? state.flatTrees[state.treeSelectedIndex]
2725
+ : state.flatIssues[state.selectedIndex]) ?? null;
2337
2726
  const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
2338
- 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 === "reviews", label: `2 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 === "mode-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select mode:" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "p" }), " Plan"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "i" }), " Implement"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }) })) : state.overlay === "context-input" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, width: Math.min(columns - 8, 100), children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), _jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => {
2727
+ const activeTracker = getIssueTracker(repoRootRef.current);
2728
+ 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] }), _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) => {
2729
+ const sel = i === state.trackerSelectIndex;
2730
+ return (_jsxs(Text, { color: sel ? "cyan" : undefined, bold: sel, children: [sel ? "> " : " ", org.name, " (", org.slug, ")"] }, org.slug));
2731
+ }), _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: " " }), [
2732
+ { label: "Local", hint: "built-in, file-based — no account needed" },
2733
+ { label: "Linear", hint: "OAuth workspace" },
2734
+ { label: "GitHub", hint: "GitHub Issues via gh CLI" },
2735
+ ].map((t, i) => {
2736
+ const sel = i === state.trackerSelectIndex;
2737
+ return (_jsxs(Text, { children: [_jsxs(Text, { color: sel ? "cyan" : undefined, bold: sel, children: [sel ? "> " : " ", t.label] }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", t.hint] })] }, t.label));
2738
+ }), _jsx(Text, { children: " " }), state.trackerSelectMessage ? (_jsx(Text, { color: "yellow", children: state.trackerSelectMessage })) : null, _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to select, q to quit" })] })) }) })) : state.overlay === "confirm-delete-issue" ? (_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: "Delete issue?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [selectedIssue?.issue.identifier, " ", selectedIssue?.issue.title] }), _jsx(Text, { dimColor: true, children: "This removes the issue file from .santree/issues/" }), _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 === "issue-form" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, width: Math.min(columns - 8, 100), children: [_jsxs(Text, { bold: true, color: "cyan", children: [state.issueFormMode === "edit" ? `Edit ${state.issueFormId}` : "New issue", " · ", state.issueFormPhase === "title"
2739
+ ? "title"
2740
+ : state.issueFormPhase === "description"
2741
+ ? "description"
2742
+ : "saving…"] }), state.issueFormError ? (_jsx(Text, { color: "red", children: state.issueFormError })) : (_jsx(Text, { dimColor: true, children: state.issueFormPhase === "title"
2743
+ ? "First line is the title"
2744
+ : "Markdown body — Ctrl+D to save" })), _jsx(Text, { children: " " }), state.issueFormPhase === "saving" ? (_jsx(Text, { color: "cyan", children: "Saving\u2026" })) : state.issueFormPhase === "title" ? (_jsx(MultilineTextArea, { value: state.issueFormTitle, onChange: (v) => dispatch({ type: "ISSUE_FORM_TITLE", title: v }), onSubmit: () => dispatch({ type: "ISSUE_FORM_PHASE", phase: "description" }), onCancel: () => dispatch({ type: "ISSUE_FORM_CLOSE" }), width: Math.min(columns - 8, 100), height: 3, placeholder: "Issue title\u2026" })) : (_jsx(MultilineTextArea, { value: state.issueFormDescription, onChange: (v) => dispatch({ type: "ISSUE_FORM_DESC", description: v }), onSubmit: () => void submitIssueForm(), onCancel: () => dispatch({ type: "ISSUE_FORM_CLOSE" }), width: Math.min(columns - 8, 100), height: 10, placeholder: "Description (optional)\u2026" })), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), state.issueFormPhase === "title" ? " next · " : " save · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+G" }), " cancel"] })] }) })) : state.overlay === "mode-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select mode:" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "p" }), " Plan"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "i" }), " Implement"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }) })) : state.overlay === "context-input" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, width: Math.min(columns - 8, 100), children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), _jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => {
2339
2745
  const mode = state.contextInputMode;
2340
2746
  const ctx = state.contextInputValue;
2341
2747
  dispatch({ type: "CONTEXT_INPUT_DONE" });
@@ -2346,17 +2752,22 @@ export default function Dashboard() {
2346
2752
  const defaultBranch = getDefaultBranch();
2347
2753
  const label = branch === defaultBranch ? `${branch} (default)` : branch;
2348
2754
  return (_jsx(Text, { children: _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", label] }) }, branch));
2349
- }), _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.flatIssues.length === 0 ? (_jsx(Box, { width: leftWidth, height: contentHeight, justifyContent: "center", alignItems: "center", children: _jsx(Text, { color: "yellow", children: "No active issues" }) })) : (_jsx(IssueList, { groups: state.groups, flatIssues: state.flatIssues, selectedIndex: state.selectedIndex, scrollOffset: 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
2755
+ }), _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 ===
2756
+ 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"
2757
+ ? state.treeListScrollOffset
2758
+ : 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
2350
2759
  .split("\n")
2351
2760
  .slice(-(contentHeight - 1))
2352
- .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.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: getIssueTracker(repoRootRef.current).displayName }) })] })) })] })] }));
2761
+ .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 })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.activeTab === "trees"
2762
+ ? state.treeDetailScrollOffset
2763
+ : 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 }) })] })) })] })] }));
2353
2764
  }
2354
2765
  /**
2355
2766
  * Renders the per-issue action key hints (Resume / Editor / View diff / …)
2356
2767
  * lifted out of the detail panels so they sit on the same row as the global
2357
2768
  * command bar. Empty when nothing is selected.
2358
2769
  */
2359
- function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, trackerName, }) {
2770
+ function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, trackerName, canMutate, }) {
2360
2771
  // During the diff overlay, none of the per-issue actions apply (View diff
2361
2772
  // is what got us here, Commit/PR/etc. need the detail panel context). Keep
2362
2773
  // the row blank so the diff-specific CommandBar reads cleanly.
@@ -2367,7 +2778,7 @@ function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, trackerN
2367
2778
  ? buildReviewActions(selectedReview)
2368
2779
  : []
2369
2780
  : selectedIssue
2370
- ? buildIssueActions(selectedIssue, trackerName)
2781
+ ? buildIssueActions(selectedIssue, trackerName, { tab: activeTab, canMutate })
2371
2782
  : [];
2372
2783
  if (items.length === 0)
2373
2784
  return _jsx(Text, { children: " " });