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.
- package/README.md +8 -8
- package/dist/commands/dashboard.js +420 -46
- package/dist/commands/issue/setup.d.ts +2 -0
- package/dist/commands/issue/setup.js +108 -0
- package/dist/commands/issue/switch.d.ts +1 -0
- package/dist/commands/issue/switch.js +2 -2
- package/dist/commands/worktree/work.js +1 -1
- package/dist/lib/ai.js +4 -0
- package/dist/lib/dashboard/DetailPanel.d.ts +5 -2
- package/dist/lib/dashboard/DetailPanel.js +18 -1
- package/dist/lib/dashboard/Overlays.js +14 -0
- package/dist/lib/dashboard/data.d.ts +2 -0
- package/dist/lib/dashboard/data.js +56 -54
- package/dist/lib/dashboard/types.d.ts +77 -2
- package/dist/lib/dashboard/types.js +93 -1
- package/dist/lib/multiplexer/cmux.js +0 -15
- package/dist/lib/multiplexer/none.js +0 -3
- package/dist/lib/multiplexer/tmux.js +0 -8
- package/dist/lib/multiplexer/types.d.ts +0 -1
- package/dist/lib/session-signal.d.ts +5 -3
- package/dist/lib/session-signal.js +5 -22
- package/dist/lib/trackers/config.js +1 -1
- package/dist/lib/trackers/index.d.ts +11 -0
- package/dist/lib/trackers/index.js +26 -0
- package/dist/lib/trackers/local/frontmatter.d.ts +12 -0
- package/dist/lib/trackers/local/frontmatter.js +91 -0
- package/dist/lib/trackers/local/index.d.ts +2 -0
- package/dist/lib/trackers/local/index.js +102 -0
- package/dist/lib/trackers/local/store.d.ts +30 -0
- package/dist/lib/trackers/local/store.js +203 -0
- package/dist/lib/trackers/types.d.ts +26 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -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
|
|
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" ? "
|
|
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
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
//
|
|
277
|
-
//
|
|
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
|
-
|
|
282
|
+
treeGroups.unshift({
|
|
281
283
|
name: "Main repo",
|
|
282
284
|
id: null,
|
|
283
285
|
statusGroups: [{ name: "Main", type: "main", issues: [mainEntry] }],
|
|
284
286
|
});
|
|
285
|
-
|
|
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.
|