Skip to content

Commit 6ada73b

Browse files
arul28claude
andauthored
tutorial flow: onboarding tour revamp + ade-cli shell-profile install (#197)
* ship: checkpoint before automate/finalize * test: pin prsTour fallback + tab-switch invariants Detail-drawer steps now carry fallback skip paths and dispatch ade:tour-pr-detail-tab to switch tabs in-flight. These invariants are load-bearing for the "user can never get stuck on a tutorial step" contract and easy to break in future edits, so pin them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ship: iter 1 — rebase on main + address Greptile P2 + CodeRabbit review - adeCliService: distinguish "added vs already present" install message via ShellPathResult { profilePath, modified }; handle fish shell explicitly with fish_add_path in ~/.config/fish/config.fish (Greptile P2 #1, #3) - DeepLinkRouter.swift: return nil from resolvePrId when numeric lookup misses the workspace snapshot, so PrsRootScreen can degrade gracefully instead of silently dropping the deep-link nav (Greptile P2 #2 / CR #7) - firstJourneyTour.test.ts: drop tautological waitForSelector self-compare; assert string + non-empty length directly (CR #1) - firstJourneyTour.ts: swap "Act 6 — PRs" / "Act 7 — History" comments to match underlying act7Intro/act6Intro IDs already referenced by tests (CR #3) - lanesTour.ts: change "Switch between lanes" fallback selector from lanes.newLane (overlapping prior step) to lanes.statusChips (CR #4) - prsTour.test.ts: rename test description from stale "Path to Merge" to current "What's blocking me?" step title (CR #5) Tests: 29/29 scoped pass (adeCliService + firstJourney + prsTour). Typecheck: clean. Swift parse: clean. Addressed comment ids: 3141711542, 3141711558, 3141711581, 3141713614, 3141713631, 3141713638, 3141713642, 3141713651 --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8064f76 commit 6ada73b

32 files changed

Lines changed: 1046 additions & 478 deletions

apps/ade-cli/src/cli.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -338,9 +338,9 @@ describe("ADE CLI", () => {
338338
});
339339

340340
expect(graph).toContain("ADE lanes");
341-
expect(graph).toContain("\\- main [main]");
342-
expect(graph).toContain("|- child [feature]");
343-
expect(graph).toContain("\\- sibling [feature-2]");
341+
expect(graph).toContain("\\- main (id: main) [main]");
342+
expect(graph).toContain("|- child (id: child) [feature]");
343+
expect(graph).toContain("\\- sibling (id: sibling) [feature-2]");
344344
});
345345

346346
it("accepts --option=value syntax equivalently to --option value", () => {
@@ -782,7 +782,7 @@ describe("ADE CLI", () => {
782782
expect(summarized).toMatchObject({
783783
lanes: expect.any(Array),
784784
});
785-
expect((summarized as any).visual).toContain("\\- main [main]");
786-
expect((summarized as any).visual).toContain("\\- child [feature]");
785+
expect((summarized as any).visual).toContain("\\- main (id: main) [main]");
786+
expect((summarized as any).visual).toContain("\\- child (id: child) [feature]");
787787
});
788788
});

apps/ade-cli/src/cli.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2646,8 +2646,9 @@ function renderLaneGraph(result: unknown): string {
26462646
const branch = asString(lane.branchRef) ?? "";
26472647
const status = asString(lane.status) ?? "";
26482648
const archived = asString(lane.archivedAt) ? " archived" : "";
2649-
lines.push(`${prefix}${isLast ? "\\- " : "|- "}${name}${branch ? ` [${branch}]` : ""}${status ? ` ${status}` : ""}${archived}`);
26502649
const id = asString(lane.id);
2650+
const idSuffix = id ? ` (id: ${id})` : "";
2651+
lines.push(`${prefix}${isLast ? "\\- " : "|- "}${name}${idSuffix}${branch ? ` [${branch}]` : ""}${status ? ` ${status}` : ""}${archived}`);
26512652
const children = id ? byParent.get(id) ?? [] : [];
26522653
children.forEach((child, index) => visit(child, `${prefix}${isLast ? " " : "| "}`, index === children.length - 1));
26532654
};

apps/desktop/src/main/services/cli/adeCliService.test.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,213 @@ describe("createAdeCliService", () => {
179179
}
180180
});
181181

182+
it("adds the user install dir to the shell profile when installing Terminal access", async () => {
183+
const root = makeTempRoot();
184+
const home = path.join(root, "home");
185+
const resourcesPath = path.join(root, "Resources");
186+
const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin");
187+
const packagedCommandPath = path.join(packagedBinDir, "ade");
188+
const installerPath = path.join(resourcesPath, "ade-cli", "install-path.sh");
189+
writeExecutable(packagedCommandPath);
190+
writeExecutable(installerPath);
191+
fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n");
192+
193+
const service = createAdeCliService({
194+
isPackaged: true,
195+
resourcesPath,
196+
userDataPath: path.join(root, "user-data"),
197+
appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"),
198+
env: { HOME: home, SHELL: "/bin/zsh", PATH: "/usr/bin:/bin" },
199+
logger: logger() as any,
200+
});
201+
202+
const result = await service.installForUser();
203+
const profilePath = path.join(home, ".zshrc");
204+
const profile = fs.readFileSync(profilePath, "utf8");
205+
206+
expect(result.ok).toBe(true);
207+
expect(result.message).toContain(`added ${path.join(home, ".local", "bin")} to ${profilePath}`);
208+
expect(profile).toContain("# ADE CLI");
209+
expect(profile).toContain('export PATH="$HOME/.local/bin:$PATH"');
210+
});
211+
212+
it("writes to ~/.bashrc when SHELL is bash", async () => {
213+
const root = makeTempRoot();
214+
const home = path.join(root, "home");
215+
const resourcesPath = path.join(root, "Resources");
216+
const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin");
217+
writeExecutable(path.join(packagedBinDir, "ade"));
218+
writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.sh"));
219+
fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n");
220+
221+
const service = createAdeCliService({
222+
isPackaged: true,
223+
resourcesPath,
224+
userDataPath: path.join(root, "user-data"),
225+
appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"),
226+
env: { HOME: home, SHELL: "/usr/local/bin/bash", PATH: "/usr/bin:/bin" },
227+
logger: logger() as any,
228+
});
229+
230+
const result = await service.installForUser();
231+
const profilePath = path.join(home, ".bashrc");
232+
233+
expect(result.ok).toBe(true);
234+
expect(result.message).toContain(profilePath);
235+
expect(fs.readFileSync(profilePath, "utf8")).toContain('export PATH="$HOME/.local/bin:$PATH"');
236+
});
237+
238+
it("falls back to ~/.profile when SHELL is unrecognized", async () => {
239+
const root = makeTempRoot();
240+
const home = path.join(root, "home");
241+
const resourcesPath = path.join(root, "Resources");
242+
const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin");
243+
writeExecutable(path.join(packagedBinDir, "ade"));
244+
writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.sh"));
245+
fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n");
246+
247+
const service = createAdeCliService({
248+
isPackaged: true,
249+
resourcesPath,
250+
userDataPath: path.join(root, "user-data"),
251+
appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"),
252+
env: { HOME: home, SHELL: "/usr/bin/nu", PATH: "/usr/bin:/bin" },
253+
logger: logger() as any,
254+
});
255+
256+
const result = await service.installForUser();
257+
const profilePath = path.join(home, ".profile");
258+
259+
expect(result.ok).toBe(true);
260+
expect(result.message).toContain(profilePath);
261+
expect(fs.readFileSync(profilePath, "utf8")).toContain('export PATH="$HOME/.local/bin:$PATH"');
262+
});
263+
264+
it("writes fish-syntax PATH update to ~/.config/fish/config.fish for fish shell", async () => {
265+
const root = makeTempRoot();
266+
const home = path.join(root, "home");
267+
const resourcesPath = path.join(root, "Resources");
268+
const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin");
269+
writeExecutable(path.join(packagedBinDir, "ade"));
270+
writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.sh"));
271+
fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n");
272+
273+
const service = createAdeCliService({
274+
isPackaged: true,
275+
resourcesPath,
276+
userDataPath: path.join(root, "user-data"),
277+
appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"),
278+
env: { HOME: home, SHELL: "/usr/bin/fish", PATH: "/usr/bin:/bin" },
279+
logger: logger() as any,
280+
});
281+
282+
const result = await service.installForUser();
283+
const profilePath = path.join(home, ".config", "fish", "config.fish");
284+
285+
expect(result.ok).toBe(true);
286+
expect(result.message).toContain(profilePath);
287+
const profile = fs.readFileSync(profilePath, "utf8");
288+
expect(profile).toContain("# ADE CLI");
289+
expect(profile).toContain("fish_add_path -gP $HOME/.local/bin");
290+
expect(profile).not.toContain("export PATH=");
291+
});
292+
293+
it("skips the shell-profile write when the install dir is already on PATH", async () => {
294+
const root = makeTempRoot();
295+
const home = path.join(root, "home");
296+
const resourcesPath = path.join(root, "Resources");
297+
const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin");
298+
writeExecutable(path.join(packagedBinDir, "ade"));
299+
writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.sh"));
300+
fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n");
301+
const targetDir = path.join(home, ".local", "bin");
302+
// Simulate an ade binary already at the install location so getStatus
303+
// reports it as installed once PATH contains targetDir.
304+
writeExecutable(path.join(targetDir, "ade"));
305+
306+
const service = createAdeCliService({
307+
isPackaged: true,
308+
resourcesPath,
309+
userDataPath: path.join(root, "user-data"),
310+
appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"),
311+
env: { HOME: home, SHELL: "/bin/zsh", PATH: `${targetDir}:/usr/bin:/bin` },
312+
logger: logger() as any,
313+
});
314+
315+
const result = await service.installForUser();
316+
const profilePath = path.join(home, ".zshrc");
317+
318+
expect(result.ok).toBe(true);
319+
expect(result.message).toBe("Installed ade for Terminal access.");
320+
expect(fs.existsSync(profilePath)).toBe(false);
321+
});
322+
323+
it("does not append the PATH line twice when the marker is already present", async () => {
324+
const root = makeTempRoot();
325+
const home = path.join(root, "home");
326+
const resourcesPath = path.join(root, "Resources");
327+
const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin");
328+
writeExecutable(path.join(packagedBinDir, "ade"));
329+
writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.sh"));
330+
fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n");
331+
332+
const profilePath = path.join(home, ".zshrc");
333+
const seeded = "# previous user content\n\n# ADE CLI\nexport PATH=\"$HOME/.local/bin:$PATH\"\n";
334+
fs.mkdirSync(home, { recursive: true });
335+
fs.writeFileSync(profilePath, seeded);
336+
337+
const service = createAdeCliService({
338+
isPackaged: true,
339+
resourcesPath,
340+
userDataPath: path.join(root, "user-data"),
341+
appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"),
342+
env: { HOME: home, SHELL: "/bin/zsh", PATH: "/usr/bin:/bin" },
343+
logger: logger() as any,
344+
});
345+
346+
const result = await service.installForUser();
347+
348+
expect(result.ok).toBe(true);
349+
expect(result.message).toContain(profilePath);
350+
expect(result.message).toContain("PATH entry already present");
351+
expect(result.message).not.toMatch(/and added .* to /);
352+
// Profile contents are unchanged — exactly one ADE CLI marker, exactly one PATH line.
353+
const profile = fs.readFileSync(profilePath, "utf8");
354+
expect(profile).toBe(seeded);
355+
expect(profile.match(/# ADE CLI/g)?.length).toBe(1);
356+
});
357+
358+
it("inserts a leading newline when the existing profile has no trailing newline", async () => {
359+
const root = makeTempRoot();
360+
const home = path.join(root, "home");
361+
const resourcesPath = path.join(root, "Resources");
362+
const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin");
363+
writeExecutable(path.join(packagedBinDir, "ade"));
364+
writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.sh"));
365+
fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n");
366+
367+
const profilePath = path.join(home, ".zshrc");
368+
fs.mkdirSync(home, { recursive: true });
369+
fs.writeFileSync(profilePath, "alias foo=bar"); // no trailing newline
370+
371+
const service = createAdeCliService({
372+
isPackaged: true,
373+
resourcesPath,
374+
userDataPath: path.join(root, "user-data"),
375+
appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"),
376+
env: { HOME: home, SHELL: "/bin/zsh", PATH: "/usr/bin:/bin" },
377+
logger: logger() as any,
378+
});
379+
380+
const result = await service.installForUser();
381+
expect(result.ok).toBe(true);
382+
383+
const profile = fs.readFileSync(profilePath, "utf8");
384+
expect(profile.startsWith("alias foo=bar\n")).toBe(true);
385+
expect(profile).toContain("\n# ADE CLI\n");
386+
expect(profile).toContain('export PATH="$HOME/.local/bin:$PATH"\n');
387+
});
388+
182389
it("creates a dev shim under userData without changing global PATH", () => {
183390
const root = makeTempRoot();
184391
const repoRoot = path.join(root, "repo");

apps/desktop/src/main/services/cli/adeCliService.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -397,12 +397,58 @@ function resolveCliPaths(args: CreateAdeCliServiceArgs): ResolvedCliPaths {
397397
};
398398
}
399399

400-
function installTargetPath(): string {
400+
function homeDir(env: NodeJS.ProcessEnv = process.env): string {
401+
return env.HOME?.trim() || os.homedir();
402+
}
403+
404+
function installTargetPath(env: NodeJS.ProcessEnv = process.env): string {
401405
if (process.platform === "win32") {
402-
const localAppData = process.env.LOCALAPPDATA?.trim() || path.join(os.homedir(), "AppData", "Local");
406+
const localAppData = env.LOCALAPPDATA?.trim() || path.join(homeDir(env), "AppData", "Local");
403407
return path.join(localAppData, "ADE", "bin", "ade.cmd");
404408
}
405-
return path.join(os.homedir(), ".local", "bin", "ade");
409+
return path.join(homeDir(env), ".local", "bin", "ade");
410+
}
411+
412+
type ShellProfile = { path: string; flavor: "posix" | "fish" };
413+
414+
function shellProfilePath(env: NodeJS.ProcessEnv = process.env): ShellProfile {
415+
const shell = env.SHELL?.trim() ?? "";
416+
const home = homeDir(env);
417+
if (shell.endsWith("zsh")) return { path: path.join(home, ".zshrc"), flavor: "posix" };
418+
if (shell.endsWith("bash")) return { path: path.join(home, ".bashrc"), flavor: "posix" };
419+
if (shell.endsWith("fish")) return { path: path.join(home, ".config", "fish", "config.fish"), flavor: "fish" };
420+
return { path: path.join(home, ".profile"), flavor: "posix" };
421+
}
422+
423+
function shellPathEntry(targetDir: string, env: NodeJS.ProcessEnv = process.env): string {
424+
const home = homeDir(env);
425+
const relativeToHome = path.relative(home, targetDir);
426+
if (relativeToHome && !relativeToHome.startsWith("..") && !path.isAbsolute(relativeToHome)) {
427+
return `$HOME/${relativeToHome.split(path.sep).join("/")}`;
428+
}
429+
return targetDir;
430+
}
431+
432+
type ShellPathResult = { profilePath: string; modified: boolean };
433+
434+
function ensureUserBinOnShellPath(
435+
targetDir: string,
436+
env: NodeJS.ProcessEnv = process.env,
437+
): ShellPathResult | null {
438+
if (process.platform === "win32" || pathContainsDir(getPathEnvValue(env), targetDir)) return null;
439+
const profile = shellProfilePath(env);
440+
const entry = shellPathEntry(targetDir, env);
441+
const marker = "# ADE CLI";
442+
const line =
443+
profile.flavor === "fish" ? `fish_add_path -gP ${entry}` : `export PATH="${entry}:$PATH"`;
444+
const existing = fs.existsSync(profile.path) ? fs.readFileSync(profile.path, "utf8") : "";
445+
if (existing.includes(marker) || existing.includes(line) || existing.includes(targetDir)) {
446+
return { profilePath: profile.path, modified: false };
447+
}
448+
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
449+
fs.mkdirSync(path.dirname(profile.path), { recursive: true });
450+
fs.appendFileSync(profile.path, `${prefix}\n${marker}\n${line}\n`);
451+
return { profilePath: profile.path, modified: true };
406452
}
407453

408454
function statusMessage(args: {
@@ -466,7 +512,7 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) {
466512

467513
const getStatus = async (): Promise<AdeCliStatus> => {
468514
const terminalCommandPath = resolveCommandOnPath("ade", hostPathSnapshot, envSnapshot);
469-
const targetPath = installTargetPath();
515+
const targetPath = installTargetPath(envSnapshot);
470516
const targetDir = path.dirname(targetPath);
471517
const terminalInstalled = Boolean(terminalCommandPath);
472518
const bundledAvailable = Boolean(resolved.commandPath && isExecutable(resolved.commandPath));
@@ -518,10 +564,16 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) {
518564
if (result.status !== 0) {
519565
throw new Error(result.stderr.trim() || result.stdout.trim() || "ADE CLI installer failed.");
520566
}
567+
const targetDir = path.dirname(installTargetPath(envSnapshot));
568+
const profileResult = ensureUserBinOnShellPath(targetDir, envSnapshot);
521569
const status = await getStatus();
522570
return {
523571
ok: true,
524-
message: status.installTargetDirOnPath
572+
message: profileResult
573+
? profileResult.modified
574+
? `Installed ade for Terminal access and added ${targetDir} to ${profileResult.profilePath}. Open a new terminal or source that file.`
575+
: `Installed ade for Terminal access. PATH entry already present in ${profileResult.profilePath}; open a new terminal or source that file.`
576+
: status.installTargetDirOnPath
525577
? "Installed ade for Terminal access."
526578
: `Installed ade at ${status.installTargetPath}. Add ${path.dirname(status.installTargetPath)} to PATH if your shell cannot find it.`,
527579
status,

apps/desktop/src/renderer/components/app/AppShell.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
listContextDocsByHealth,
5252
} from "../context/contextShared";
5353
import { disposeTerminalRuntimesForProjectChange } from "../terminals/TerminalView";
54+
import { buildPrsRouteSearch, type PrDetailRouteTab } from "../prs/prsRouteState";
5455

5556
type PrToast = {
5657
id: string;
@@ -1234,9 +1235,23 @@ export function AppShell({ children }: { children: React.ReactNode }) {
12341235
type="button"
12351236
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border/60 bg-transparent px-3 text-[11px] font-medium text-fg/85 transition-colors hover:border-fg/20 hover:bg-fg/[0.04] hover:text-fg"
12361237
onClick={() => {
1237-
selectLane(toast.event.laneId);
1238-
setLaneInspectorTab(toast.event.laneId, "merge");
1239-
window.location.hash = `#/lanes?laneId=${encodeURIComponent(toast.event.laneId)}&focus=single&inspectorTab=merge`;
1238+
let detailTab: PrDetailRouteTab | null = null;
1239+
if (toast.event.kind === "checks_failing") {
1240+
detailTab = "checks";
1241+
} else if (
1242+
toast.event.kind === "changes_requested" ||
1243+
toast.event.kind === "review_requested"
1244+
) {
1245+
detailTab = "activity";
1246+
}
1247+
const search = buildPrsRouteSearch({
1248+
activeTab: "normal",
1249+
selectedPrId: toast.event.prId,
1250+
selectedQueueGroupId: null,
1251+
selectedRebaseItemId: null,
1252+
detailTab,
1253+
});
1254+
navigate(`/prs${search}`);
12401255
dismissPrToast(toast.id);
12411256
}}
12421257
>

apps/desktop/src/renderer/components/settings/GeneralSection.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
LABEL_STYLE,
1212
primaryButton,
1313
} from "../lanes/laneDesignTokens";
14+
import { AdeCliSection } from "./AdeCliSection";
1415

1516
const sectionLabelStyle: React.CSSProperties = {
1617
...LABEL_STYLE,
@@ -103,6 +104,11 @@ export function GeneralSection() {
103104
</div>
104105
</section>
105106

107+
<section>
108+
<div style={sectionLabelStyle}>ADE CLI</div>
109+
<AdeCliSection compact />
110+
</section>
111+
106112
<section>
107113
<div style={sectionLabelStyle}>AI MODE</div>
108114
<div style={{ ...cardStyle(), display: "flex", flexDirection: "column", gap: 12 }}>

0 commit comments

Comments
 (0)