santree 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ export declare const description = "Pick and configure the issue tracker for this repo";
2
+ export default function IssueSetup(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,108 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text, Box, useInput } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import { findMainRepoRoot } from "../../lib/git.js";
6
+ import { setRepoTracker, getIssueTracker } from "../../lib/trackers/index.js";
7
+ import { setRepoLinearOrg } from "../../lib/trackers/linear/index.js";
8
+ import { readLinearAuthStore } from "../../lib/trackers/auth-store.js";
9
+ import { getAuthenticatedUser, getCurrentRepoNwo } from "../../lib/trackers/github/auth.js";
10
+ export const description = "Pick and configure the issue tracker for this repo";
11
+ const TRACKERS = [
12
+ { kind: "local", label: "Local", hint: "built-in, file-based — no account needed" },
13
+ { kind: "linear", label: "Linear", hint: "OAuth workspace" },
14
+ { kind: "github", label: "GitHub", hint: "GitHub Issues via gh CLI" },
15
+ ];
16
+ export default function IssueSetup() {
17
+ const [phase, setPhase] = useState("checking");
18
+ const [message, setMessage] = useState("");
19
+ const [error, setError] = useState(null);
20
+ const [trackerIdx, setTrackerIdx] = useState(0);
21
+ const [orgs, setOrgs] = useState([]);
22
+ const [orgIdx, setOrgIdx] = useState(0);
23
+ const [repoRoot, setRepoRoot] = useState(null);
24
+ useEffect(() => {
25
+ const root = findMainRepoRoot();
26
+ if (!root) {
27
+ setError("Not inside a git repository");
28
+ setPhase("error");
29
+ return;
30
+ }
31
+ setRepoRoot(root);
32
+ setPhase("pick-tracker");
33
+ }, []);
34
+ function finish(label) {
35
+ setMessage(label);
36
+ setPhase("done");
37
+ }
38
+ async function chooseTracker(kind) {
39
+ const root = repoRoot;
40
+ if (kind === "local") {
41
+ setRepoTracker(root, "local");
42
+ finish(`Active tracker for this repo: ${getIssueTracker(root).displayName}`);
43
+ return;
44
+ }
45
+ if (kind === "linear") {
46
+ const store = readLinearAuthStore();
47
+ const list = Object.entries(store).map(([slug, tokens]) => ({
48
+ slug,
49
+ name: tokens.org_name,
50
+ }));
51
+ if (list.length === 0) {
52
+ setError("No authenticated Linear workspaces. Run: santree linear auth");
53
+ setPhase("error");
54
+ return;
55
+ }
56
+ if (list.length === 1) {
57
+ setRepoLinearOrg(root, list[0].slug);
58
+ setRepoTracker(root, "linear");
59
+ finish(`Linked to ${list[0].name} (${list[0].slug})`);
60
+ return;
61
+ }
62
+ setOrgs(list);
63
+ setOrgIdx(0);
64
+ setPhase("pick-org");
65
+ return;
66
+ }
67
+ // github
68
+ setPhase("checking");
69
+ const user = await getAuthenticatedUser();
70
+ if (!user) {
71
+ setError("GitHub CLI not authenticated. Run: santree github auth");
72
+ setPhase("error");
73
+ return;
74
+ }
75
+ setRepoTracker(root, "github");
76
+ const nwo = await getCurrentRepoNwo(root);
77
+ finish(`Active tracker: GitHub (@${user.login}${nwo ? ` · ${nwo}` : ""})`);
78
+ }
79
+ useInput((_input, key) => {
80
+ if (phase === "pick-tracker") {
81
+ if (key.upArrow)
82
+ setTrackerIdx((i) => Math.max(0, i - 1));
83
+ else if (key.downArrow)
84
+ setTrackerIdx((i) => Math.min(TRACKERS.length - 1, i + 1));
85
+ else if (key.return)
86
+ void chooseTracker(TRACKERS[trackerIdx].kind);
87
+ }
88
+ else if (phase === "pick-org") {
89
+ if (key.upArrow)
90
+ setOrgIdx((i) => Math.max(0, i - 1));
91
+ else if (key.downArrow)
92
+ setOrgIdx((i) => Math.min(orgs.length - 1, i + 1));
93
+ else if (key.return) {
94
+ const org = orgs[orgIdx];
95
+ setRepoLinearOrg(repoRoot, org.slug);
96
+ setRepoTracker(repoRoot, "linear");
97
+ finish(`Linked to ${org.name} (${org.slug})`);
98
+ }
99
+ }
100
+ });
101
+ useEffect(() => {
102
+ if (phase === "done" || phase === "error") {
103
+ const timer = setTimeout(() => process.exit(phase === "error" ? 1 : 0), 100);
104
+ return () => clearTimeout(timer);
105
+ }
106
+ }, [phase]);
107
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Issue tracker setup" }) }), phase === "checking" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Checking\u2026" })] })), phase === "pick-tracker" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select the issue tracker for this repo:" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: TRACKERS.map((t, i) => (_jsxs(Text, { children: [i === trackerIdx ? (_jsx(Text, { color: "cyan", bold: true, children: "> " })) : (_jsx(Text, { children: " " })), t.label, " ", _jsxs(Text, { dimColor: true, children: ["\u2014 ", t.hint] })] }, t.kind))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 to select, Enter to confirm" }) })] })), phase === "pick-org" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select a Linear workspace to link:" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: orgs.map((org, i) => (_jsxs(Text, { children: [i === orgIdx ? (_jsx(Text, { color: "cyan", bold: true, children: "> " })) : (_jsx(Text, { children: " " })), org.name, " (", org.slug, ")"] }, org.slug))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 to select, Enter to confirm" }) })] })), phase === "done" && _jsxs(Text, { color: "green", children: ["\u2713 ", message] }), phase === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] }));
108
+ }
@@ -3,6 +3,7 @@ export declare const description = "Switch the active issue tracker for this rep
3
3
  export declare const args: z.ZodTuple<[z.ZodEnum<{
4
4
  linear: "linear";
5
5
  github: "github";
6
+ local: "local";
6
7
  }>], null>;
7
8
  type Props = {
8
9
  args: z.infer<typeof args>;
@@ -7,9 +7,9 @@ import { findMainRepoRoot } from "../../lib/git.js";
7
7
  import { setRepoTracker, getIssueTracker } from "../../lib/trackers/index.js";
8
8
  export const description = "Switch the active issue tracker for this repo";
9
9
  export const args = z.tuple([
10
- z.enum(["linear", "github"]).describe(argument({
10
+ z.enum(["linear", "github", "local"]).describe(argument({
11
11
  name: "kind",
12
- description: "Tracker kind: linear or github",
12
+ description: "Tracker kind: linear, github, or local (built-in)",
13
13
  })),
14
14
  ]);
15
15
  export default function IssueSwitch({ args }) {
@@ -99,5 +99,5 @@ export default function Work({ options }) {
99
99
  setError(err instanceof Error ? err.message : "Failed to launch agent");
100
100
  }
101
101
  }, [status, aiContext, mode]);
102
- return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Work" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : getModeColor(mode), paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: getModeColor(mode), color: "white", bold: true, children: ` ${getModeLabel(mode)} ` })] })] }), _jsxs(Box, { marginTop: 1, children: [status === "loading" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading..." })] })), status === "fetching" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Fetching issue from tracker..." })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }), _jsxs(Text, { dimColor: true, children: [" ", "claude", mode === "plan" ? " --permission-mode plan" : "", " ", `"<${getModeLabel(mode)} prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
102
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Work" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : getModeColor(mode), paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: getModeColor(mode), color: "white", bold: true, children: ` ${getModeLabel(mode)} ` })] })] }), _jsxs(Box, { marginTop: 1, children: [status === "loading" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading..." })] })), status === "fetching" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Fetching issue from tracker..." })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude..." }), _jsxs(Text, { dimColor: true, children: [" ", "claude --permission-mode ", mode === "plan" ? "plan" : "auto", " ", `"<${getModeLabel(mode)} prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
103
103
  }
package/dist/lib/ai.js CHANGED
@@ -185,6 +185,10 @@ export function launchAgent(prompt, opts) {
185
185
  throw new Error("Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code");
186
186
  }
187
187
  const args = [];
188
+ // Plan mode uses Claude Code's `--permission-mode plan` (read-only,
189
+ // restrictive); implement runs use `auto`. Auto-acceptance of non-mutating
190
+ // tools while planning is governed by the user's `useAutoModeDuringPlan`
191
+ // setting in ~/.claude/settings.json, not by santree.
188
192
  args.push("--permission-mode", opts?.planMode ? "plan" : "auto");
189
193
  if (opts?.sessionId) {
190
194
  if (opts.resume) {
@@ -1,4 +1,4 @@
1
- import type { DashboardIssue } from "./types.js";
1
+ import type { DashboardIssue, DashboardTab } from "./types.js";
2
2
  interface Props {
3
3
  issue: DashboardIssue | null;
4
4
  scrollOffset: number;
@@ -18,6 +18,9 @@ export type IssueActionItem = {
18
18
  * `trackerName` is the active tracker's `displayName` ("Linear" / "GitHub"),
19
19
  * surfaced as the open-in-browser action label so the panel doesn't hardcode
20
20
  * a vendor name. */
21
- export declare function buildIssueActions(di: DashboardIssue, trackerName: string): IssueActionItem[];
21
+ export declare function buildIssueActions(di: DashboardIssue, trackerName: string, opts?: {
22
+ tab?: DashboardTab;
23
+ canMutate?: boolean;
24
+ }): IssueActionItem[];
22
25
  export default function DetailPanel({ issue, scrollOffset, height, width, creatingForTicket, creationLogs, }: Props): import("react/jsx-runtime").JSX.Element;
23
26
  export {};
@@ -57,9 +57,26 @@ function fileColor(xy) {
57
57
  * `trackerName` is the active tracker's `displayName` ("Linear" / "GitHub"),
58
58
  * surfaced as the open-in-browser action label so the panel doesn't hardcode
59
59
  * a vendor name. */
60
- export function buildIssueActions(di, trackerName) {
60
+ export function buildIssueActions(di, trackerName, opts) {
61
61
  const { worktree, pr, issue } = di;
62
62
  const items = [];
63
+ // Issues tab = backlog/planning. No worktree actions here (commit / PR /
64
+ // diff / fix live on the Trees tab). Offer Work (start → creates a
65
+ // worktree, moving the row to Trees) plus issue CRUD when the active
66
+ // tracker supports mutation (built-in Local only — feature-detected via
67
+ // `canMutate`, never a kind string check).
68
+ if (opts?.tab === "issues") {
69
+ items.push({ key: "w", label: "Work", color: "cyan" });
70
+ if (opts.canMutate) {
71
+ items.push({ key: "n", label: "New", color: "cyan" });
72
+ items.push({ key: "e", label: "Edit", color: "cyan" });
73
+ items.push({ key: "d", label: "Delete", color: "red" });
74
+ }
75
+ if (issue.url) {
76
+ items.push({ key: "o", label: trackerName, color: "gray" });
77
+ }
78
+ return items;
79
+ }
63
80
  // The synthetic "Main repo" row is special: no PR/Switch/Resume/Remove,
64
81
  // no work-launching (you're already on it). Only commit / diff /
65
82
  // editor — the actions that make sense for "I have changes in main and
@@ -27,6 +27,20 @@ export function PrCreateOverlay({ width, height, branch, ticketId, phase, error,
27
27
  .map((line, i) => (_jsx(Text, { wrap: "truncate", children: line || " " }, i))), (body ?? "").split("\n").length > Math.max(4, height - 12) && (_jsxs(Text, { dimColor: true, children: ["\u2026+", (body ?? "").split("\n").length - Math.max(4, height - 12), " more lines"] }))] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " / ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), " create ", _jsx(Text, { color: "yellow", bold: true, children: "e" }), " keep editing ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " open in browser ", _jsx(Text, { color: "red", bold: true, children: "ESC" }), " cancel"] })] })), phase === "creating" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Creating PR..."] })), phase === "done" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "green", bold: true, children: "PR created!" }), url ? _jsx(Text, { dimColor: true, children: url }) : null] })), phase === "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "open in browser ESC cancel"] })] })), phase !== "review" && phase !== "confirm" && phase !== "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }))] }));
28
28
  }
29
29
  const LEGEND = [
30
+ {
31
+ title: "Tabs & keys",
32
+ rows: [
33
+ { glyph: "1", color: "cyan", meaning: "Issues tab — backlog / planning" },
34
+ { glyph: "2", color: "cyan", meaning: "Trees tab — worktrees in progress" },
35
+ { glyph: "3", color: "cyan", meaning: "Reviews tab — PRs awaiting your review" },
36
+ { glyph: "Tab", color: "cyan", meaning: "Cycle Issues → Trees → Reviews" },
37
+ { glyph: "t", color: "cyan", meaning: "Switch / set up the issue tracker" },
38
+ { glyph: "n", color: "cyan", meaning: "New issue (built-in tracker, Issues tab)" },
39
+ { glyph: "e", color: "cyan", meaning: "Edit issue (built-in tracker, Issues tab)" },
40
+ { glyph: "d", color: "red", meaning: "Delete issue (built-in) / remove worktree (Trees)" },
41
+ { glyph: "w", color: "cyan", meaning: "Start work (creates a worktree → Trees tab)" },
42
+ ],
43
+ },
30
44
  {
31
45
  title: "Issue list",
32
46
  rows: [
@@ -2,6 +2,8 @@ import type { DashboardIssue, ProjectGroup, EnrichedReviewPR } from "./types.js"
2
2
  export declare function loadDashboardData(repoRoot: string): Promise<{
3
3
  groups: ProjectGroup[];
4
4
  flatIssues: DashboardIssue[];
5
+ treeGroups: ProjectGroup[];
6
+ flatTrees: DashboardIssue[];
5
7
  }>;
6
8
  export declare function loadReviewsData(repoRoot: string): Promise<{
7
9
  flatReviews: EnrichedReviewPR[];
@@ -203,16 +203,6 @@ export async function loadDashboardData(repoRoot) {
203
203
  parent.children.push(di);
204
204
  childTicketIds.add(ticketId);
205
205
  }
206
- // Group by project (excluding children — they'll appear nested under parents)
207
- const groupMap = new Map();
208
- for (const di of enriched) {
209
- if (childTicketIds.has(di.issue.identifier))
210
- continue;
211
- const key = di.issue.projectName ?? "No Project";
212
- const list = groupMap.get(key) ?? [];
213
- list.push(di);
214
- groupMap.set(key, list);
215
- }
216
206
  // Status type priority: started > unstarted > backlog > triage
217
207
  const statusTypePriority = {
218
208
  started: 0,
@@ -220,44 +210,35 @@ export async function loadDashboardData(repoRoot) {
220
210
  backlog: 2,
221
211
  triage: 3,
222
212
  };
223
- const groups = [...groupMap.entries()].map(([name, issues]) => {
224
- // Sub-group by status
225
- const statusMap = new Map();
213
+ // Group a set of (non-child) issues by project, then by status.
214
+ function buildProjectGroups(issues) {
215
+ const groupMap = new Map();
226
216
  for (const di of issues) {
227
- const statusName = di.issue.state.name;
228
- const existing = statusMap.get(statusName);
229
- if (existing) {
230
- existing.issues.push(di);
231
- }
232
- else {
233
- statusMap.set(statusName, {
234
- name: statusName,
235
- type: di.issue.state.type,
236
- issues: [di],
237
- });
238
- }
217
+ if (childTicketIds.has(di.issue.identifier))
218
+ continue;
219
+ const key = di.issue.projectName ?? "No Project";
220
+ const list = groupMap.get(key) ?? [];
221
+ list.push(di);
222
+ groupMap.set(key, list);
239
223
  }
240
- // Sort status groups by type priority
241
- const statusGroups = [...statusMap.values()].sort((a, b) => (statusTypePriority[a.type] ?? 99) - (statusTypePriority[b.type] ?? 99));
242
- return {
243
- name,
244
- id: issues[0]?.issue.projectId ?? null,
245
- statusGroups,
246
- };
247
- });
248
- // Append orphaned worktrees as a separate group at the bottom (excluding children)
249
- const topLevelOrphans = orphans.filter((di) => !childTicketIds.has(di.issue.identifier));
250
- if (topLevelOrphans.length > 0) {
251
- groups.push({
252
- name: "Orphaned Worktrees",
253
- id: null,
254
- statusGroups: [
255
- {
256
- name: "Orphaned",
257
- type: "orphaned",
258
- issues: topLevelOrphans,
259
- },
260
- ],
224
+ return [...groupMap.entries()].map(([name, list]) => {
225
+ const statusMap = new Map();
226
+ for (const di of list) {
227
+ const statusName = di.issue.state.name;
228
+ const existing = statusMap.get(statusName);
229
+ if (existing) {
230
+ existing.issues.push(di);
231
+ }
232
+ else {
233
+ statusMap.set(statusName, {
234
+ name: statusName,
235
+ type: di.issue.state.type,
236
+ issues: [di],
237
+ });
238
+ }
239
+ }
240
+ const statusGroups = [...statusMap.values()].sort((a, b) => (statusTypePriority[a.type] ?? 99) - (statusTypePriority[b.type] ?? 99));
241
+ return { name, id: list[0]?.issue.projectId ?? null, statusGroups };
261
242
  });
262
243
  }
263
244
  // Flatten with children inserted right after their parent
@@ -270,21 +251,42 @@ export async function loadDashboardData(repoRoot) {
270
251
  }
271
252
  return result;
272
253
  }
273
- const flatIssues = groups.flatMap((g) => g.statusGroups.flatMap((sg) => sg.issues.flatMap(flattenWithChildren)));
274
- // Synthesize a "Main repo" row at the very top so users can commit /
275
- // view diffs / inspect drift on whatever branch their main checkout
276
- // happens to be on. The row uses the same WorktreeInfo shape but with
277
- // state.type === "main" so the renderer can differentiate.
254
+ function flatten(g) {
255
+ return g.flatMap((grp) => grp.statusGroups.flatMap((sg) => sg.issues.flatMap(flattenWithChildren)));
256
+ }
257
+ // ── Partition: Issues tab (backlog/planning) vs Trees tab (work in
258
+ // progress). A tracker issue with no worktree is backlog; once it gains a
259
+ // worktree it moves to the Trees tab. Children always have a worktree, so
260
+ // they only ever appear nested in Trees. Main-repo + orphaned worktrees
261
+ // belong to Trees (they're active checkouts, not backlog).
262
+ const backlogIssues = enriched.filter((di) => !di.worktree);
263
+ const treeIssues = enriched.filter((di) => di.worktree);
264
+ const groups = buildProjectGroups(backlogIssues);
265
+ const flatIssues = flatten(groups);
266
+ const treeGroups = buildProjectGroups(treeIssues);
267
+ const topLevelOrphans = orphans.filter((di) => !childTicketIds.has(di.issue.identifier));
268
+ if (topLevelOrphans.length > 0) {
269
+ treeGroups.push({
270
+ name: "Orphaned Worktrees",
271
+ id: null,
272
+ statusGroups: [{ name: "Orphaned", type: "orphaned", issues: topLevelOrphans }],
273
+ });
274
+ }
275
+ const flatTrees = flatten(treeGroups);
276
+ // Synthesize a "Main repo" row at the top of the Trees tab so users can
277
+ // commit / view diffs / inspect drift on whatever branch their main
278
+ // checkout happens to be on. state.type === "main" lets the renderer
279
+ // differentiate.
278
280
  const mainEntry = await buildMainEntry(repoRoot);
279
281
  if (mainEntry) {
280
- groups.unshift({
282
+ treeGroups.unshift({
281
283
  name: "Main repo",
282
284
  id: null,
283
285
  statusGroups: [{ name: "Main", type: "main", issues: [mainEntry] }],
284
286
  });
285
- flatIssues.unshift(mainEntry);
287
+ flatTrees.unshift(mainEntry);
286
288
  }
287
- return { groups, flatIssues };
289
+ return { groups, flatIssues, treeGroups, flatTrees };
288
290
  }
289
291
  /** Build the synthetic dashboard row for the main repo checkout — the
290
292
  * non-worktree clone that the user typically commits master/main from.
@@ -72,8 +72,19 @@ export interface EnrichedReviewPR {
72
72
  * Null when the user hasn't set one or the lookup failed. */
73
73
  authorName: string | null;
74
74
  }
75
- export type DashboardTab = "issues" | "reviews";
76
- export type ActionOverlay = "mode-select" | "context-input" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | "diff" | "help" | null;
75
+ export type DashboardTab = "issues" | "trees" | "reviews";
76
+ export type ActionOverlay = "mode-select" | "context-input" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | "diff" | "help" | "tracker-select" | "issue-form" | "confirm-delete-issue" | null;
77
+ /** Tracker-selection overlay sub-phase: pick a tracker, then (for Linear with
78
+ * multiple authenticated workspaces) pick the org. */
79
+ export type TrackerSelectPhase = "root" | "linear-org";
80
+ /** Issue create/edit form sub-phase. Title and description are captured in
81
+ * two sequential MultilineTextArea steps (reusing the context-input pattern);
82
+ * "saving" blocks input while the tracker writes the file. */
83
+ export type IssueFormPhase = "title" | "description" | "saving";
84
+ export interface TrackerOrgOption {
85
+ slug: string;
86
+ name: string;
87
+ }
77
88
  export type DiffFileStatus = "M" | "A" | "D" | "R" | "C" | "U" | "?";
78
89
  export interface DiffFile {
79
90
  path: string;
@@ -96,6 +107,21 @@ export interface DashboardState {
96
107
  reviewSelectedIndex: number;
97
108
  reviewListScrollOffset: number;
98
109
  reviewDetailScrollOffset: number;
110
+ treeGroups: ProjectGroup[];
111
+ flatTrees: DashboardIssue[];
112
+ treeSelectedIndex: number;
113
+ treeListScrollOffset: number;
114
+ treeDetailScrollOffset: number;
115
+ trackerSelectPhase: TrackerSelectPhase;
116
+ trackerSelectIndex: number;
117
+ trackerSelectOrgs: TrackerOrgOption[];
118
+ trackerSelectMessage: string | null;
119
+ issueFormMode: "create" | "edit" | null;
120
+ issueFormPhase: IssueFormPhase;
121
+ issueFormId: string | null;
122
+ issueFormTitle: string;
123
+ issueFormDescription: string;
124
+ issueFormError: string | null;
99
125
  loading: boolean;
100
126
  refreshing: boolean;
101
127
  error: string | null;
@@ -154,9 +180,58 @@ export type DashboardAction = {
154
180
  type: "SET_DATA";
155
181
  groups: ProjectGroup[];
156
182
  flatIssues: DashboardIssue[];
183
+ treeGroups: ProjectGroup[];
184
+ flatTrees: DashboardIssue[];
157
185
  } | {
158
186
  type: "SELECT";
159
187
  index: number;
188
+ } | {
189
+ type: "TREE_SELECT";
190
+ index: number;
191
+ } | {
192
+ type: "TREE_SCROLL_LIST";
193
+ offset: number;
194
+ } | {
195
+ type: "TREE_SCROLL_DETAIL";
196
+ offset: number;
197
+ } | {
198
+ type: "TRACKER_SELECT_OPEN";
199
+ } | {
200
+ type: "TRACKER_SELECT_MOVE";
201
+ index: number;
202
+ } | {
203
+ type: "TRACKER_SELECT_PHASE";
204
+ phase: TrackerSelectPhase;
205
+ orgs?: TrackerOrgOption[];
206
+ } | {
207
+ type: "TRACKER_SELECT_MESSAGE";
208
+ message: string | null;
209
+ } | {
210
+ type: "TRACKER_SELECT_CLOSE";
211
+ } | {
212
+ type: "ISSUE_FORM_OPEN";
213
+ mode: "create" | "edit";
214
+ id: string | null;
215
+ title: string;
216
+ description: string;
217
+ } | {
218
+ type: "ISSUE_FORM_PHASE";
219
+ phase: IssueFormPhase;
220
+ } | {
221
+ type: "ISSUE_FORM_TITLE";
222
+ title: string;
223
+ } | {
224
+ type: "ISSUE_FORM_DESC";
225
+ description: string;
226
+ } | {
227
+ type: "ISSUE_FORM_ERROR";
228
+ error: string;
229
+ } | {
230
+ type: "ISSUE_FORM_CLOSE";
231
+ } | {
232
+ type: "ISSUE_DELETE_OPEN";
233
+ } | {
234
+ type: "ISSUE_DELETE_CLOSE";
160
235
  } | {
161
236
  type: "SCROLL_LIST";
162
237
  offset: number;
@@ -10,6 +10,21 @@ export const initialState = {
10
10
  reviewSelectedIndex: 0,
11
11
  reviewListScrollOffset: 0,
12
12
  reviewDetailScrollOffset: 0,
13
+ treeGroups: [],
14
+ flatTrees: [],
15
+ treeSelectedIndex: 0,
16
+ treeListScrollOffset: 0,
17
+ treeDetailScrollOffset: 0,
18
+ trackerSelectPhase: "root",
19
+ trackerSelectIndex: 0,
20
+ trackerSelectOrgs: [],
21
+ trackerSelectMessage: null,
22
+ issueFormMode: null,
23
+ issueFormPhase: "title",
24
+ issueFormId: null,
25
+ issueFormTitle: "",
26
+ issueFormDescription: "",
27
+ issueFormError: null,
13
28
  loading: true,
14
29
  refreshing: false,
15
30
  error: null,
@@ -60,7 +75,7 @@ export const initialState = {
60
75
  export function reducer(state, action) {
61
76
  switch (action.type) {
62
77
  case "SET_DATA": {
63
- // Preserve selection by identifier if possible
78
+ // Preserve selection by identifier if possible (both tabs)
64
79
  const prevId = state.flatIssues[state.selectedIndex]?.issue.identifier;
65
80
  let newIndex = 0;
66
81
  if (prevId) {
@@ -68,17 +83,94 @@ export function reducer(state, action) {
68
83
  if (found >= 0)
69
84
  newIndex = found;
70
85
  }
86
+ const prevTreeId = state.flatTrees[state.treeSelectedIndex]?.issue.identifier;
87
+ let newTreeIndex = 0;
88
+ if (prevTreeId) {
89
+ const found = action.flatTrees.findIndex((d) => d.issue.identifier === prevTreeId);
90
+ if (found >= 0)
91
+ newTreeIndex = found;
92
+ }
71
93
  return {
72
94
  ...state,
73
95
  groups: action.groups,
74
96
  flatIssues: action.flatIssues,
97
+ treeGroups: action.treeGroups,
98
+ flatTrees: action.flatTrees,
75
99
  selectedIndex: newIndex,
100
+ treeSelectedIndex: newTreeIndex,
76
101
  loading: false,
77
102
  refreshing: false,
78
103
  error: null,
79
104
  detailScrollOffset: 0,
105
+ treeDetailScrollOffset: 0,
80
106
  };
81
107
  }
108
+ case "TREE_SELECT":
109
+ return { ...state, treeSelectedIndex: action.index, treeDetailScrollOffset: 0 };
110
+ case "TREE_SCROLL_LIST":
111
+ return { ...state, treeListScrollOffset: action.offset };
112
+ case "TREE_SCROLL_DETAIL":
113
+ return { ...state, treeDetailScrollOffset: action.offset };
114
+ case "TRACKER_SELECT_OPEN":
115
+ return {
116
+ ...state,
117
+ overlay: "tracker-select",
118
+ trackerSelectPhase: "root",
119
+ trackerSelectIndex: 0,
120
+ trackerSelectOrgs: [],
121
+ trackerSelectMessage: null,
122
+ loading: false,
123
+ refreshing: false,
124
+ error: null,
125
+ };
126
+ case "TRACKER_SELECT_MOVE":
127
+ return { ...state, trackerSelectIndex: action.index };
128
+ case "TRACKER_SELECT_PHASE":
129
+ return {
130
+ ...state,
131
+ trackerSelectPhase: action.phase,
132
+ trackerSelectIndex: 0,
133
+ trackerSelectOrgs: action.orgs ?? state.trackerSelectOrgs,
134
+ trackerSelectMessage: null,
135
+ };
136
+ case "TRACKER_SELECT_MESSAGE":
137
+ return { ...state, trackerSelectMessage: action.message };
138
+ case "TRACKER_SELECT_CLOSE":
139
+ return { ...state, overlay: null, trackerSelectMessage: null };
140
+ case "ISSUE_FORM_OPEN":
141
+ return {
142
+ ...state,
143
+ overlay: "issue-form",
144
+ issueFormMode: action.mode,
145
+ issueFormPhase: "title",
146
+ issueFormId: action.id,
147
+ issueFormTitle: action.title,
148
+ issueFormDescription: action.description,
149
+ issueFormError: null,
150
+ };
151
+ case "ISSUE_FORM_PHASE":
152
+ return { ...state, issueFormPhase: action.phase };
153
+ case "ISSUE_FORM_TITLE":
154
+ return { ...state, issueFormTitle: action.title };
155
+ case "ISSUE_FORM_DESC":
156
+ return { ...state, issueFormDescription: action.description };
157
+ case "ISSUE_FORM_ERROR":
158
+ return { ...state, issueFormPhase: "description", issueFormError: action.error };
159
+ case "ISSUE_FORM_CLOSE":
160
+ return {
161
+ ...state,
162
+ overlay: null,
163
+ issueFormMode: null,
164
+ issueFormPhase: "title",
165
+ issueFormId: null,
166
+ issueFormTitle: "",
167
+ issueFormDescription: "",
168
+ issueFormError: null,
169
+ };
170
+ case "ISSUE_DELETE_OPEN":
171
+ return { ...state, overlay: "confirm-delete-issue" };
172
+ case "ISSUE_DELETE_CLOSE":
173
+ return { ...state, overlay: null };
82
174
  case "SELECT":
83
175
  return { ...state, selectedIndex: action.index, detailScrollOffset: 0 };
84
176
  case "SCROLL_LIST":
@@ -55,21 +55,6 @@ export const cmuxMultiplexer = {
55
55
  const result = cmuxRun(`cmux select-workspace --workspace ${shellEscape(ws.ref)}`);
56
56
  return result.ok ? { ok: true } : { ok: false, reason: "failed" };
57
57
  },
58
- renameWindow(currentName, newName) {
59
- // `workspace-action --action rename --title <text>` defaults to the caller's
60
- // workspace via $CMUX_WORKSPACE_ID. When `currentName` is provided we look up
61
- // that specific workspace's ref instead.
62
- let target = "";
63
- if (currentName) {
64
- const ws = findWorkspaceByTitle(currentName);
65
- if (!ws?.ref) {
66
- return { ok: false, reason: "failed", message: "cmux workspace not found" };
67
- }
68
- target = ` --workspace ${shellEscape(ws.ref)}`;
69
- }
70
- const result = cmuxRun(`cmux workspace-action --action rename --title ${shellEscape(newName)}${target}`);
71
- return result.ok ? { ok: true } : { ok: false, reason: "failed" };
72
- },
73
58
  sendCommand(_name, _command) {
74
59
  // Blocked by manaflow-ai/cmux#1472 — programmatically created workspaces have
75
60
  // dead PTYs, so post-creation `cmux send` / `send-key` silently drop input.
@@ -10,9 +10,6 @@ export const noneMultiplexer = {
10
10
  async selectWindow() {
11
11
  return NOT_ACTIVE;
12
12
  },
13
- renameWindow() {
14
- return NOT_ACTIVE;
15
- },
16
13
  sendCommand() {
17
14
  return NOT_ACTIVE;
18
15
  },