diff --git a/apps/desktop/lint-baseline.json b/apps/desktop/lint-baseline.json index 35ee95a03f..086ea4fc2f 100644 --- a/apps/desktop/lint-baseline.json +++ b/apps/desktop/lint-baseline.json @@ -13,9 +13,6 @@ "ade-ui/no-fixed-overlay": 1, "ade-ui/no-raw-z-index": 1 }, - "apps/desktop/src/renderer/components/app/TabNav.tsx": { - "ade-ui/no-fixed-overlay": 1 - }, "apps/desktop/src/renderer/components/capture/CaptureFlyIn.tsx": { "ade-ui/no-fixed-overlay": 1, "ade-ui/no-raw-z-index": 1 @@ -90,12 +87,15 @@ "ade-ui/no-raw-z-index": 1 }, "apps/desktop/src/renderer/components/lanes/LanesPage.tsx": { - "ade-ui/no-fixed-overlay": 4, - "ade-ui/no-raw-z-index": 7 + "ade-ui/no-fixed-overlay": 2, + "ade-ui/no-raw-z-index": 2 }, "apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx": { "ade-ui/no-raw-z-index": 1 }, + "apps/desktop/src/renderer/components/lanes/sidebar/PointMenu.tsx": { + "ade-ui/no-fixed-overlay": 1 + }, "apps/desktop/src/renderer/components/onboarding/HelpMenu.tsx": { "ade-ui/no-fixed-overlay": 1, "ade-ui/no-raw-z-index": 1 @@ -166,6 +166,9 @@ "apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx": { "ade-ui/no-fixed-overlay": 1 }, + "apps/desktop/src/renderer/components/ui/AnchoredMenu.tsx": { + "ade-ui/no-fixed-overlay": 1 + }, "apps/desktop/src/renderer/components/ui/MenuSubmenu.tsx": { "ade-ui/no-fixed-overlay": 1, "ade-ui/no-raw-z-index": 1 @@ -181,10 +184,6 @@ "apps/desktop/src/renderer/components/ui/paneMenuTokens.ts": { "ade-ui/no-raw-z-index": 1 }, - "apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx": { - "ade-ui/no-fixed-overlay": 1, - "ade-ui/no-raw-z-index": 1 - }, "apps/desktop/src/renderer/webclient/shell/ScreenShell.tsx": { "ade-ui/no-fixed-overlay": 2 } diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index ff6bb57dad..137ba89431 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -64,7 +64,6 @@ import type { } from "../../../shared/types"; import { AUTOMATION_TRIGGER_TYPES, NO_DEFAULT_LANE_TEMPLATE } from "../../../shared/types"; import { - DEFAULT_LANE_BANNER_BUDGET, DEFAULT_REBASE_SUGGESTIONS, DEFAULT_REBASE_SUGGESTION_MIN_BEHIND, laneSetupScriptHasWork, @@ -2067,13 +2066,11 @@ function coerceConfigFile(value: unknown): ProjectConfigFile { const newLaneBaseSource = asNewLaneBaseSource(value.git.newLaneBaseSource); const rebaseSuggestions = asRebaseSuggestionDisplay(value.git.rebaseSuggestions); const rebaseSuggestionMinBehind = asNonNegativeInt(value.git.rebaseSuggestionMinBehind); - const laneBannerBudget = asNonNegativeInt(value.git.laneBannerBudget); const out: NonNullable = {}; if (autoRebaseOnHeadChange != null) out.autoRebaseOnHeadChange = autoRebaseOnHeadChange; if (newLaneBaseSource) out.newLaneBaseSource = newLaneBaseSource; if (rebaseSuggestions) out.rebaseSuggestions = rebaseSuggestions; if (rebaseSuggestionMinBehind != null) out.rebaseSuggestionMinBehind = rebaseSuggestionMinBehind; - if (laneBannerBudget != null) out.laneBannerBudget = laneBannerBudget; return Object.keys(out).length ? out : undefined; })(); @@ -2513,7 +2510,6 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF newLaneBaseSource: mergedGit?.newLaneBaseSource ?? "remote", rebaseSuggestions: mergedGit?.rebaseSuggestions ?? DEFAULT_REBASE_SUGGESTIONS, rebaseSuggestionMinBehind: mergedGit?.rebaseSuggestionMinBehind ?? DEFAULT_REBASE_SUGGESTION_MIN_BEHIND, - laneBannerBudget: mergedGit?.laneBannerBudget ?? DEFAULT_LANE_BANNER_BUDGET, }, ...(effectiveAi ? { ai: effectiveAi } : {}), ...(mergedProviders ? { providers: mergedProviders } : {}), diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 978410eeca..67dba71b07 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -878,10 +878,76 @@ function buildMockLanesFromAdeSnapshot(laneRows: any[]): any[] { }); } +/** + * Stand-in lanes appended to the snapshot's real lanes so every Lanes sidebar + * State group has a member: a lane whose PR merged (Done, and behind main on + * purpose, since merged lanes usually are), an old lane with no PR (Stale) plus + * a stacked child in the same group (indented), and a fresh child of the first + * live-PR lane (Quiet, shown with a "↳ parent" hint because its parent sits in + * another group). The Done lane's merged PR is added in `mockLiveLanePrs`. + */ +const MOCK_SIDEBAR_DONE_LANE_ID = "mock-sidebar-done"; +function mockSidebarGroupLanes(realLanes: any[]): any[] { + const hoursAgo = (hours: number) => new Date(Date.now() - hours * 3_600_000).toISOString(); + const liveParent = realLanes.find( + (lane: any) => lane.laneType !== "primary" && !String(lane.name ?? "").startsWith("t3code/"), + ); + const primaryId = realLanes.find((lane: any) => lane.laneType === "primary")?.id ?? null; + const withCommit = (lane: any, lastCommitAt: string, behind = 0) => ({ + ...lane, + parentLaneId: lane.parentLaneId === "lane-main" ? primaryId : lane.parentLaneId, + lastCommitAt, + status: { ...lane.status, behind, lastCommitAt }, + }); + const lanes = [ + withCommit( + makeLane(MOCK_SIDEBAR_DONE_LANE_ID, "Usage meter polish", "refs/heads/ade/usage-meter-polish", { + createdAt: hoursAgo(4 * 24), + color: "#34d399", + }), + hoursAgo(2 * 24), + 6, + ), + withCommit( + makeLane("mock-sidebar-stale", "Onboarding copy spike", "refs/heads/ade/onboarding-copy-spike", { + createdAt: hoursAgo(45 * 24), + childCount: 1, + }), + hoursAgo(31 * 24), + ), + withCommit( + makeLane("mock-sidebar-stale-child", "Onboarding empty states", "refs/heads/ade/onboarding-empty-states", { + createdAt: hoursAgo(40 * 24), + parentLaneId: "mock-sidebar-stale", + stackDepth: 1, + }), + hoursAgo(33 * 24), + ), + ]; + if (liveParent) { + liveParent.childCount = (liveParent.childCount ?? 0) + 1; + lanes.push( + withCommit( + makeLane("mock-sidebar-child", "Timeline filters", "refs/heads/ade/timeline-filters", { + createdAt: hoursAgo(20), + parentLaneId: liveParent.id, + baseRef: String(liveParent.branchRef ?? "main").replace(/^refs\/heads\//, ""), + stackDepth: 1, + }), + hoursAgo(5), + ), + ); + } + return lanes; +} + const MOCK_LANES: any[] = USE_ADE_DB_SNAPSHOT - ? buildMockLanesFromAdeSnapshot( - Array.isArray(ADE_DB_SNAPSHOT?.lanes) ? ADE_DB_SNAPSHOT.lanes : [], - ) + ? (() => { + const real = buildMockLanesFromAdeSnapshot( + Array.isArray(ADE_DB_SNAPSHOT?.lanes) ? ADE_DB_SNAPSHOT.lanes : [], + ); + return [...real, ...mockSidebarGroupLanes(real)]; + })() : BUILTIN_MOCK_LANES; const ADE_DB_PR_SNAPSHOTS: any[] = @@ -891,6 +957,64 @@ const ADE_DB_PR_SNAPSHOTS: any[] = const ADE_DB_PR_SNAPSHOT_BY_ID = new Map( ADE_DB_PR_SNAPSHOTS.map((snapshot) => [String(snapshot.prId), snapshot]), ); +/** + * The snapshot has no git log, so lanes get a stand-in: real commit subjects + * from the exported PR snapshots, dated back from the lane's newest commit. + * The newest `ahead` rows play the lane's own commits and a few older rows play + * base history. Messages carry Co-Authored-By trailers so agent attribution shows. + */ +const MOCK_COMMIT_POOL: any[] = (() => { + const seen = new Set(); + const out: any[] = []; + for (const snapshot of ADE_DB_PR_SNAPSHOTS) { + for (const commit of snapshot?.commits ?? []) { + if (!commit?.sha || seen.has(commit.sha)) continue; + seen.add(commit.sha); + out.push(commit); + } + } + return out; +})(); +const MOCK_COMMIT_TRAILERS = [ + "Claude Opus 5.5 ", + "Codex ", + "Claude Opus 5.5 ", + "Cursor Agent ", + null, +]; +const MOCK_COMMIT_MESSAGES = new Map(); + +function mockLaneRecentCommits(args: any = {}): any[] | null { + const lane = MOCK_LANES.find((row) => row.id === args?.laneId); + if (!lane || MOCK_COMMIT_POOL.length === 0) return null; + const limit = Number.isFinite(args?.limit) ? Math.max(1, Math.floor(args.limit)) : 30; + const ahead = lane.laneType === "primary" ? limit : Math.max(0, lane.status?.ahead ?? 0); + const count = Math.min(limit, ahead + 5); + let seed = 0; + for (const ch of String(lane.id)) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0; + const end = Date.parse(lane.lastCommitAt ?? "") || Date.now() - 20 * 60_000; + const start = Date.parse(lane.createdAt ?? "") || end - 7 * 86_400_000; + const step = Math.max(12 * 60_000, (end - start) / Math.max(1, Math.min(ahead, count))); + const lanePrefix = String(lane.id).replace(/[^0-9a-f]/gi, "").padEnd(8, "0").slice(0, 8); + return Array.from({ length: count }, (_, index) => { + const source = MOCK_COMMIT_POOL[(seed + index) % MOCK_COMMIT_POOL.length]; + const sourceSha = String(source.sha); + const sha = `${sourceSha.slice(0, 7)}${index.toString(16).padStart(4, "0")}${lanePrefix}${sourceSha.slice(19)}`; + const subject = String(source.message ?? "").split("\n")[0] ?? ""; + const trailer = MOCK_COMMIT_TRAILERS[(seed + index) % MOCK_COMMIT_TRAILERS.length]; + MOCK_COMMIT_MESSAGES.set(sha, trailer ? `${subject}\n\nCo-Authored-By: ${trailer}` : subject); + return { + sha, + shortSha: sha.slice(0, 7), + parents: [], + authorName: String(source.author?.name ?? "ADE"), + authoredAt: new Date(end - index * step).toISOString(), + subject, + pushed: index > 0, + }; + }); +} + const ADE_DB_OPERATIONS: any[] = USE_ADE_DB_SNAPSHOT && Array.isArray(ADE_DB_SNAPSHOT?.operations) ? ADE_DB_SNAPSHOT.operations @@ -1680,11 +1804,101 @@ const INTEGRATION_PRS: any[] = [ ), ]; +/** + * The exported snapshot only carries PRs of lanes that are long gone, so the + * Lanes dashboard would never show a live PR. Three live lanes get stand-in + * PRs built from their own name and branch: open with failing checks and + * changes requested (plus one earlier merged PR), a draft with checks + * running, and an open approved PR with passing checks. + */ +function mockLiveLanePrs(): any[] { + const lanes = MOCK_LANES.filter( + (lane: any) => lane.laneType !== "primary" && !String(lane.name ?? "").startsWith("t3code/"), + ).slice(0, 3); + const looks = [ + { state: "open", checksStatus: "failing", reviewStatus: "changes_requested", mergeConflicts: true, additions: 412, deletions: 96 }, + { state: "draft", checksStatus: "pending", reviewStatus: "none", mergeConflicts: false, additions: 58, deletions: 12 }, + { state: "open", checksStatus: "passing", reviewStatus: "approved", mergeConflicts: false, additions: 1204, deletions: 377 }, + ]; + const out: any[] = []; + // The sidebar's Done stand-in lane (see `mockSidebarGroupLanes`) merged. + const doneLane = MOCK_LANES.find((lane: any) => lane.id === MOCK_SIDEBAR_DONE_LANE_ID); + if (doneLane) { + out.push({ + laneId: doneLane.id, + projectId: MOCK_PROJECT.id ?? "mock-project", + repoOwner: "arul28", + repoName: "ADE", + githubNodeId: null, + baseBranch: "main", + lastSyncedAt: now, + id: "mock-sidebar-pr-1227", + githubPrNumber: 1227, + githubUrl: "https://github.com/arul28/ADE/pull/1227", + title: String(doneLane.name), + headBranch: String(doneLane.branchRef).replace(/^refs\/heads\//, ""), + state: "merged", + checksStatus: "passing", + reviewStatus: "approved", + mergeConflicts: false, + additions: 184, + deletions: 52, + createdAt: new Date(Date.now() - 3 * 86_400_000).toISOString(), + updatedAt: new Date(Date.now() - 2 * 86_400_000).toISOString(), + mergedAt: new Date(Date.now() - 2 * 86_400_000).toISOString(), + }); + } + lanes.forEach((lane: any, index: number) => { + const look = looks[index]!; + const branch = String(lane.branchRef ?? "").replace(/^refs\/heads\//, ""); + const number = 1301 + index; + const base = { + laneId: lane.id, + projectId: MOCK_PROJECT.id ?? "mock-project", + repoOwner: "arul28", + repoName: "ADE", + githubNodeId: null, + baseBranch: "main", + lastSyncedAt: now, + }; + out.push({ + ...base, + id: `mock-live-pr-${number}`, + githubPrNumber: number, + githubUrl: `https://github.com/arul28/ADE/pull/${number}`, + title: String(lane.name ?? branch), + headBranch: branch, + ...look, + createdAt: new Date(Date.now() - (index + 1) * 26 * 3_600_000).toISOString(), + updatedAt: new Date(Date.now() - (index + 1) * 40 * 60_000).toISOString(), + mergedAt: null, + }); + if (index === 0) { + out.push({ + ...base, + id: `mock-live-pr-${number}-earlier`, + githubPrNumber: 1240, + githubUrl: "https://github.com/arul28/ADE/pull/1240", + title: `${String(lane.name ?? branch)}: first pass`, + headBranch: `${branch}-v1`, + state: "merged", + checksStatus: "passing", + reviewStatus: "approved", + mergeConflicts: false, + additions: 230, + deletions: 41, + createdAt: new Date(Date.now() - 9 * 86_400_000).toISOString(), + updatedAt: new Date(Date.now() - 7 * 86_400_000).toISOString(), + mergedAt: new Date(Date.now() - 7 * 86_400_000).toISOString(), + }); + } + }); + return out; +} + // ── All PRs combined ────────────────────────────────────────── const ALL_PRS = USE_ADE_DB_SNAPSHOT - ? Array.isArray(ADE_DB_SNAPSHOT?.prs) - ? ADE_DB_SNAPSHOT.prs - : [] + ? [...(Array.isArray(ADE_DB_SNAPSHOT?.prs) ? ADE_DB_SNAPSHOT.prs : []), ...mockLiveLanePrs()] : [...NORMAL_PRS, ...INTEGRATION_PRS]; function getAdeDbPrSnapshotByGithubCoordinates(args: any): any | null { @@ -2167,6 +2381,102 @@ const MOCK_STATUS_BY_PR: Record = { }, }; +/** + * Checks, reviews, merge status and changed files for the stand-in live lane + * PRs (see `mockLiveLanePrs`), so the Lanes overview's pull request section + * has something real to show: #1301 failing with conflicts, #1302 a draft + * with checks still running, #1303 approved and green. + */ +const MOCK_LIVE_PR_FILES: Record = {}; +(function seedMockLivePrDetail() { + const iso = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString(); + const check = (name: string, state: "passed" | "failed" | "running" | "queued" | "skipped", minutes = 4) => ({ + name, + status: state === "running" ? "in_progress" : state === "queued" ? "queued" : "completed", + conclusion: state === "passed" ? "success" : state === "failed" ? "failure" : state === "skipped" ? "skipped" : null, + detailsUrl: null, + startedAt: state === "queued" ? null : iso(60), + completedAt: state === "passed" || state === "failed" || state === "skipped" ? iso(60 - minutes) : null, + }); + const files = (paths: Array<[string, string, number, number]>) => + paths.map(([filename, status, additions, deletions]) => ({ filename, status, additions, deletions, patch: null, previousFilename: null })); + const livePrs = ALL_PRS.filter((pr: any) => String(pr.id ?? "").startsWith("mock-live-pr-") && !String(pr.id).endsWith("-earlier")); + const looks: Array<{ checks: any[]; reviews: any[]; status: Record; files: any[] }> = [ + { + checks: [ + check("ci / typecheck", "passed", 3), + check("ci / unit (desktop)", "failed", 7), + check("ci / unit (ios)", "passed", 11), + check("ci / lint", "passed", 2), + check("ci / build (macos)", "running"), + ], + reviews: [ + { reviewer: "coderabbitai", reviewerAvatarUrl: null, reviewerIsBot: true, state: "commented", body: null, submittedAt: iso(300) }, + { reviewer: "arul28", reviewerAvatarUrl: null, state: "changes_requested", body: null, submittedAt: iso(200) }, + ], + status: { mergeConflicts: true, isMergeable: false, behindBaseBy: 12 }, + files: files([ + ["apps/desktop/src/renderer/components/lanes/LanesPage.tsx", "modified", 212, 88], + ["apps/desktop/src/renderer/components/lanes/overview/LaneTimeline.tsx", "added", 164, 0], + ["apps/desktop/src/renderer/components/lanes/overview/laneTimelineModel.ts", "added", 96, 0], + ["apps/desktop/src/main/services/lanes/laneEventService.ts", "modified", 44, 8], + ["apps/desktop/src/renderer/components/lanes/LaneStackPane.tsx", "removed", 0, 131], + ["docs/features/lanes/README.md", "modified", 12, 4], + ]), + }, + { + checks: [ + check("ci / typecheck", "passed", 3), + check("ci / lint", "passed", 2), + check("ci / unit (desktop)", "running"), + check("ci / build (macos)", "queued"), + check("ci / build (windows)", "queued"), + ], + reviews: [], + status: { mergeConflicts: false, isMergeable: true, behindBaseBy: 0 }, + files: files([ + ["apps/desktop/src/main/services/macDesktop/virtualDisplay.ts", "added", 41, 0], + ["apps/desktop/src/main/services/macDesktop/displayRegistry.ts", "modified", 12, 9], + ["apps/ios/ADE/Views/MacDesktop/MacDesktopView.swift", "modified", 5, 3], + ]), + }, + { + checks: [ + "ci / typecheck", "ci / lint", "ci / unit (desktop)", "ci / unit (ios)", "ci / unit (cli)", + "ci / build (macos)", "ci / build (windows)", "ci / e2e", "CodeRabbit", + ].map((name, index) => check(name, "passed", 2 + index)), + reviews: [ + { reviewer: "arul28", reviewerAvatarUrl: null, state: "approved", body: null, submittedAt: iso(50) }, + ], + status: { mergeConflicts: false, isMergeable: true, behindBaseBy: 0 }, + files: files([ + ["apps/desktop/src/main/services/macDesktop/macDesktopSeat.ts", "modified", 388, 120], + ["apps/desktop/src/main/services/macDesktop/h264Decoder.ts", "renamed", 210, 77], + ["apps/desktop/src/renderer/components/macDesktop/MacDesktopPane.tsx", "modified", 301, 94], + ["apps/desktop/src/renderer/components/macDesktop/useMacDesktopStream.ts", "added", 142, 0], + ["apps/desktop/src/shared/types/macDesktop.ts", "modified", 36, 11], + ["apps/ade-cli/src/commands/macDesktop.ts", "modified", 58, 21], + ["apps/desktop/src/main/services/macDesktop/legacyCapture.ts", "removed", 0, 54], + ["docs/features/mac-desktop/README.md", "modified", 29, 0], + ]), + }, + ]; + livePrs.forEach((pr: any, index: number) => { + const look = looks[index]; + if (!look) return; + MOCK_CHECKS_BY_PR[pr.id] = look.checks; + MOCK_REVIEWS_BY_PR[pr.id] = look.reviews; + MOCK_STATUS_BY_PR[pr.id] = { + prId: pr.id, + state: pr.state, + checksStatus: pr.checksStatus, + reviewStatus: pr.reviewStatus, + ...look.status, + }; + MOCK_LIVE_PR_FILES[pr.id] = look.files; + }); +})(); + // ── Rebase Needs (all urgency categories) ───────────────────── const BUILTIN_MOCK_REBASE_NEEDS: any[] = [ // Attention: behind + conflicts predicted @@ -6236,19 +6546,21 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { discardFile: resolvedArg({ ok: true }), restoreStagedFile: resolvedArg({ ok: true }), commit: resolvedArg({ ok: true }), - listRecentCommits: resolvedArg([ - { - sha: "abcdef1234567890", - shortSha: "abcdef1", - parents: [], - authorName: "ADE Browser Mock", - authoredAt: now, - subject: "Browser mock HEAD commit", - pushed: true, - }, - ]), + listRecentCommits: async (args: any = {}) => + mockLaneRecentCommits(args) ?? [ + { + sha: "abcdef1234567890", + shortSha: "abcdef1", + parents: [], + authorName: "ADE Browser Mock", + authoredAt: now, + subject: "Browser mock HEAD commit", + pushed: true, + }, + ], listCommitFiles: resolvedArg([]), - getCommitMessage: resolvedArg(""), + getCommitMessage: async (args: any = {}) => + MOCK_COMMIT_MESSAGES.get(String(args?.commitSha ?? "")) ?? "", getCommit: resolvedArg(null), isCommitInLaneHistory: resolvedArg(true), revertCommit: resolvedArg({ ok: true }), @@ -6829,7 +7141,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { linkedIssues: [], }, getFiles: async (prId: string) => - ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.files ?? [], + ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.files ?? MOCK_LIVE_PR_FILES[prId] ?? [], getCommits: async (prId: string) => ADE_DB_PR_SNAPSHOT_BY_ID.get(prId)?.commits ?? [], getDeployments: resolvedArg([]), diff --git a/apps/desktop/src/renderer/components/account/AccountAvatar.tsx b/apps/desktop/src/renderer/components/account/AccountAvatar.tsx new file mode 100644 index 0000000000..0292bcee83 --- /dev/null +++ b/apps/desktop/src/renderer/components/account/AccountAvatar.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from "react"; +import { UserCircle } from "@phosphor-icons/react"; +import { accountAvatarImage, accountInitials, providerTint } from "../../lib/account"; +import type { AdeAccountStatus } from "../../../shared/types"; + +/** + * The account avatar: the account image, else a monogram, else a person glyph + * when signed out. The ring takes the sign-in provider's tint. A broken image + * falls back to the monogram, and a new image URL gets a fresh try. + */ +export function AccountAvatar({ + status, + githubLogin, + githubConnected, + size, +}: { + status: AdeAccountStatus; + githubLogin: string | null; + githubConnected: boolean; + size: number; +}) { + const image = accountAvatarImage(status, githubLogin); + const tint = providerTint(status, githubConnected); + const [broken, setBroken] = useState(false); + useEffect(() => setBroken(false), [image]); + const ring = `0 0 0 1.5px color-mix(in srgb, ${tint} 55%, transparent)`; + + if (image && !broken) { + return ( + setBroken(true)} + className="shrink-0 rounded-full object-cover" + style={{ width: size, height: size, boxShadow: ring }} + draggable={false} + /> + ); + } + if (status.signedIn) { + return ( + + {accountInitials(status)} + + ); + } + return ; +} diff --git a/apps/desktop/src/renderer/components/account/AccountPage.tsx b/apps/desktop/src/renderer/components/account/AccountPage.tsx index beaa7472e9..2e5107cb84 100644 --- a/apps/desktop/src/renderer/components/account/AccountPage.tsx +++ b/apps/desktop/src/renderer/components/account/AccountPage.tsx @@ -391,7 +391,13 @@ function SignOutCard({ onSignOut, signingOut }: { onSignOut: () => void; signing // Page. // --------------------------------------------------------------------------- -export function AccountPage() { +/** + * The account: who you are, your Macs, and sign out; or the sign-in card. + * + * `embedded` renders it as a Settings section: no page scroll, no width cap, + * no Back button. The standalone page (no project open) keeps all three. + */ +export function AccountPage({ embedded = false }: { embedded?: boolean } = {}) { const navigate = useNavigate(); const location = useLocation(); const { status, refresh } = useAccountStatus(); @@ -460,7 +466,7 @@ export function AccountPage() { [status.signedIn, githubConnected, repoBridgeDismissed], ); - const backButton = ( + const backButton = embedded ? null : ( + - - - - - ) : null} + + + ) : null} - + - void handleSignOut()} signingOut={signingOut} /> - {signOutError ? ( -
- {signOutError} -
- ) : null} - - )} - + void handleSignOut()} signingOut={signingOut} /> + {signOutError ? ( +
+ {signOutError} +
+ ) : null} + + )} + + ); + + if (embedded) return content; + return ( +
+ {content}
); } diff --git a/apps/desktop/src/renderer/components/account/AccountSignedOutBanner.test.tsx b/apps/desktop/src/renderer/components/account/AccountSignedOutBanner.test.tsx index d30c4219c8..a752ec94e2 100644 --- a/apps/desktop/src/renderer/components/account/AccountSignedOutBanner.test.tsx +++ b/apps/desktop/src/renderer/components/account/AccountSignedOutBanner.test.tsx @@ -149,6 +149,15 @@ describe("AccountSignedOutBanner", () => { expect(signedOutBanner()).toBeNull(); }); + + it("hides on the Account section of Settings, and only there", () => { + renderBanner("/settings?tab=account"); + expect(signedOutBanner()).toBeNull(); + + cleanup(); + renderBanner("/settings?tab=appearance"); + expect(signedOutBanner()).toBeTruthy(); + }); }); /** diff --git a/apps/desktop/src/renderer/components/account/AccountSignedOutBanner.tsx b/apps/desktop/src/renderer/components/account/AccountSignedOutBanner.tsx index c6794820d8..0433a53f8d 100644 --- a/apps/desktop/src/renderer/components/account/AccountSignedOutBanner.tsx +++ b/apps/desktop/src/renderer/components/account/AccountSignedOutBanner.tsx @@ -62,8 +62,10 @@ function useAccountBannerModel(navigate: NavigateFunction): BannerModel | null { if (loading) return null; // Already on the account page — the bar would point at the page you are on. - // The page's own card carries the same Reconnect button. + // The page's own card carries the same Reconnect button. Inside a project + // the account page is the Account section of Settings. if (location.pathname === "/account" || location.pathname.startsWith("/account/")) return null; + if (location.pathname === "/settings" && new URLSearchParams(location.search).get("tab") === "account") return null; if (copy) { return { diff --git a/apps/desktop/src/renderer/components/account/YourMacsCard.tsx b/apps/desktop/src/renderer/components/account/YourMacsCard.tsx index d7ac3c4beb..d7780a3658 100644 --- a/apps/desktop/src/renderer/components/account/YourMacsCard.tsx +++ b/apps/desktop/src/renderer/components/account/YourMacsCard.tsx @@ -585,7 +585,7 @@ export function YourMacsCard() { const reconnectAction = reconnectFlow.view({ label: "Reconnect this computer", detail: missingCopy.body }); // The ⋮ menu is rendered in a fixed portal so it can never be clipped by, or - // stack behind, the cards that follow this one (mirrors the TabNav pattern). + // stack behind, the cards that follow this one (same approach as `ui/AnchoredMenu`). const { ref: menuRef, position: menuPosition } = useClampedFixedPosition(menuAnchor, openMenuKey); const menuItemRef = useRef(null); const menuTriggerRef = useRef(null); diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 0949a86550..702d7ec06e 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -12,7 +12,7 @@ import { import { useShallow } from "zustand/react/shallow"; import { AppShell } from "./AppShell"; -import { resolveSettingsTab } from "../settings/settingsManifest"; +import { ACCOUNT_SETTINGS_ROUTE, resolveSettingsTab } from "../settings/settingsManifest"; import { InboundDeeplinkModal, type InboundDeeplinkDispatchOptions, @@ -25,6 +25,8 @@ import { CrossRepoPrBanner } from "./CrossRepoPrBanner"; import { ProjectRecoveryScreen } from "./ProjectRecoveryScreen"; import { ProjectHostRecoveryScreen, ProjectHostStartingBanner } from "./ProjectHostRecoveryScreen"; import { PageErrorBoundary } from "./PageErrorBoundary"; +import { ProjectSidebar } from "./projectSidebar/ProjectSidebar"; +import { ProjectSidebarHold, ProjectSidebarSlotProvider } from "./projectSidebar/ProjectSidebarSlot"; import { ProjectWelcomePage } from "../projects/ProjectWelcomePage"; import { OnboardingBootstrap } from "../onboarding/OnboardingBootstrap"; import { LaunchGate } from "../onboarding/LaunchGate"; @@ -84,9 +86,6 @@ const HistoryPage = React.lazy(() => const AutomationsPage = React.lazy(() => import("../automations/AutomationsPage").then((m) => ({ default: m.AutomationsPage })) ); -const AutomationsTemplatesPage = React.lazy(() => - import("../automations/AutomationsTemplatesPage").then((m) => ({ default: m.AutomationsTemplatesPage })) -); const SettingsPage = React.lazy(() => import("./SettingsPage").then((m) => ({ default: m.SettingsPage })) ); @@ -218,6 +217,23 @@ function isLanesRoutePath(pathname: string): boolean { return pathname === "/lanes" || pathname.startsWith("/lanes/"); } +/** + * CTO and History open over the page you were on: they fill the main area, + * and that page stays mounted behind them with its list still in the sidebar. + */ +function isOverlayRoutePath(pathname: string): boolean { + return pathname === "/cto" || pathname.startsWith("/cto/") + || pathname === "/history" || pathname.startsWith("/history/"); +} + +const HIDDEN_PAGE_STYLE: React.CSSProperties = { + position: "absolute", + inset: 0, + zIndex: -1, + opacity: 0, + pointerEvents: "none", +}; + const WARM_PROJECT_SURFACE_LIMIT = 8; const EMPTY_PROJECT_TAB_ROOTS: string[] = []; const EMPTY_PROJECT_INFO_BY_ROOT: Record = {}; @@ -339,14 +355,25 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string const setWorkViewState = useAppStore((s) => s.setWorkViewState); const workSurfaceRef = React.useRef(null); const lanesSurfaceRef = React.useRef(null); - const isWorkRoute = isWorkRoutePath(route.split(/[?#]/, 1)[0] || "/work"); - const isLanesRoute = isLanesRoutePath(route.split(/[?#]/, 1)[0] || "/work"); + const pageSurfaceRef = React.useRef(null); + const pathname = route.split(/[?#]/, 1)[0] || "/work"; + const isWorkRoute = isWorkRoutePath(pathname); + const isLanesRoute = isLanesRoutePath(pathname); + const overlayOpen = isOverlayRoutePath(pathname); + // The last page route that was not CTO or History. While one of those is + // open, this page stays mounted (hidden) and holds the sidebar. When the app + // opens straight onto CTO or History there is no such page, so none is held. + const [heldRoute, setHeldRoute] = React.useState(() => overlayOpen ? null : route); + const pageRoute = overlayOpen ? heldRoute : route; + const pagePath = pageRoute ? pageRoute.split(/[?#]/, 1)[0] || "/work" : null; + const heldWork = overlayOpen && pagePath != null && isWorkRoutePath(pagePath); + const heldLanes = overlayOpen && pagePath != null && isLanesRoutePath(pagePath); const [workRoute, setWorkRoute] = React.useState(() => isWorkRoute ? route : "/work"); const [workMounted, setWorkMounted] = React.useState(isWorkRoute); const [lanesRoute, setLanesRoute] = React.useState(() => isLanesRoute ? route : "/lanes"); - const routeProps = { active } as { active?: boolean }; - const shouldRenderWork = workMounted || isWorkRoute; - const shouldRenderLanes = active && isLanesRoute; + const pageActive = active && !overlayOpen; + const shouldRenderWork = workMounted || isWorkRoute || heldWork; + const shouldRenderLanes = active && (isLanesRoute || heldLanes); const visibleWorkRoute = isWorkRoute ? route : workRoute; const visibleLanesRoute = isLanesRoute ? route : lanesRoute; // `ade ui show` for this project's runtime. Answered here, whatever tab is @@ -365,6 +392,17 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string setLanesRoute(route); }, [isLanesRoute, route]); + React.useEffect(() => { + if (!overlayOpen) setHeldRoute(route); + }, [overlayOpen, route]); + + React.useEffect(() => { + const node = pageSurfaceRef.current; + if (!node) return; + if (overlayOpen) node.setAttribute("inert", ""); + else node.removeAttribute("inert"); + }, [overlayOpen, pagePath]); + React.useEffect(() => { if (active && isWorkRoute) return; hideBuiltInBrowserView(projectRoot); @@ -421,19 +459,13 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string className="h-full min-h-0 w-full" aria-hidden={!isWorkRoute} data-ade-animation-state={isWorkRoute ? "running" : "paused"} - style={!isWorkRoute - ? { - position: "absolute", - inset: 0, - zIndex: -1, - opacity: 0, - pointerEvents: "none", - } - : undefined} + style={!isWorkRoute ? HIDDEN_PAGE_STYLE : undefined} > - + + + @@ -446,22 +478,16 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string - + + + @@ -470,52 +496,68 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string ) : null; return ( -
- {workSurface} - {lanesSurface} - {active && !isWorkRoute && !isLanesRoute ? ( - - } /> - } /> - - {React.createElement(FilesTab as React.ComponentType<{ active?: boolean }>, routeProps)} - - } /> - - {React.createElement(PRsPage as React.ComponentType<{ active?: boolean }>, routeProps)} - - } /> - - {React.createElement(HistoryPage as React.ComponentType<{ active?: boolean }>, routeProps)} - - } /> - - {React.createElement(AutomationsPage as React.ComponentType<{ active?: boolean }>, routeProps)} - - } /> - - {React.createElement(AutomationsTemplatesPage as React.ComponentType<{ active?: boolean }>, routeProps)} - - } /> - - {React.createElement(CtoPage as React.ComponentType<{ active?: boolean }>, routeProps)} - - } /> - - {React.createElement(SettingsPage as React.ComponentType<{ active?: boolean }>, routeProps)} - - } /> - } /> - - ) : null} -
+ +
+ +
+ {workSurface} + {lanesSurface} + {active && pageRoute && pagePath && !isWorkRoutePath(pagePath) && !isLanesRoutePath(pagePath) ? ( +
+ + + } /> + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + } /> + + +
+ ) : null} + {active && overlayOpen ? ( +
+ + + + + } /> + + + + } /> + } /> + +
+ ) : null} +
+
+
); } @@ -807,6 +849,17 @@ function ProjectTabHost() { return warm; }, [activeSurfaceKey, projectEntries]); + // Inside a project, the account lives in Settings. `/account` (sign-in + // banners, the Connections panel, old links) lands there. With no project + // open there is no Settings to land in, so the standalone page stays. + const projectSurfaceOnScreen = Boolean(activeProject) && !showWelcome && mountedProjects.length > 0; + const accountOpensInSettings = location.pathname === "/account" && projectSurfaceOnScreen; + React.useEffect(() => { + if (accountOpensInSettings) { + navigate(ACCOUNT_SETTINGS_ROUTE, { replace: true, state: location.state }); + } + }, [accountOpensInSettings, location.state, navigate]); + for (const entry of mountedProjects) { if (!storesRef.current.has(entry.surfaceKey)) { storesRef.current.set(entry.surfaceKey, createProjectAppStore( @@ -935,7 +988,7 @@ function ProjectTabHost() { ) : null} - {isAccountRoute ? ( + {isAccountRoute && !accountOpensInSettings ? ( diff --git a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx index db3fa25ab9..514e16a6e8 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -172,6 +172,12 @@ vi.mock("./AppShell", async () => { }; }); +// The project sidebar reads attention state that this store mock leaves out; +// these tests cover which surfaces stay mounted, not the sidebar. +vi.mock("./projectSidebar/ProjectSidebar", () => ({ + ProjectSidebar: () => null, +})); + vi.mock("./ProjectRecoveryScreen", () => ({ ProjectRecoveryScreen: () =>
, })); @@ -856,6 +862,19 @@ describe("App Work route keep-alive", () => { expect(appStoreModule.retainProjectAppStoreState).not.toHaveBeenCalled(); }); + it("opens /account inside a project on the Settings account tab and keeps returnTo", async () => { + window.history.replaceState({ usr: { returnTo: "/files" }, key: "acct", idx: 0 }, "", "/account"); + const { App } = await import("./App"); + + render(); + + await waitFor(() => { + expect(window.location.pathname).toBe("/settings"); + expect(window.location.search).toBe("?tab=account"); + }); + expect(window.history.state?.usr?.returnTo).toBe("/files"); + }); + it("converts legacy hash app routes into BrowserRouter paths", async () => { window.history.replaceState({}, "", "/work#/lanes"); const { App } = await import("./App"); diff --git a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx index 69c2b97880..8f1f4d467c 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx @@ -1,5 +1,6 @@ /* @vitest-environment jsdom */ +import type React from "react"; import type { ReactNode } from "react"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter, useNavigate } from "react-router-dom"; @@ -18,18 +19,25 @@ vi.mock("./CommandPalette", () => ({ CommandPalette: () => null, })); -vi.mock("./TabNav", () => ({ - TabNav: ({ githubStatus }: { githubStatus: unknown }) => ( - - ), -})); - vi.mock("./TopBar", () => ({ TopBar: () =>
, })); +// The shell hands its GitHub status to the banner host; the probe reads it +// there while the real host still renders its banners. +vi.mock("./IntegrationBanners", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + IntegrationBanners: (props: React.ComponentProps) => ( + <> + {props.githubStatus ? JSON.stringify(props.githubStatus) : "none"} + + + ), + }; +}); + vi.mock("../ui/TabBackground", () => ({ TabBackground: ({ children }: { children: ReactNode }) => <>{children}, })); diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index f8a241ee6d..fbc8e0ac46 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -3,12 +3,11 @@ import { useLocation, useNavigate } from "react-router-dom"; import { AccountSignedOutBanner } from "../account/AccountSignedOutBanner"; import { CommandPalette } from "./CommandPalette"; import { IntegrationBanners } from "./IntegrationBanners"; -import { TabNav } from "./TabNav"; import { isCssZoomedBrowserSurface } from "../../lib/webClientMode"; import { TopBar } from "./TopBar"; +import { useProjectSidebarShortcuts } from "./projectSidebar/useProjectSidebarShortcuts"; import { ProjectTransitionErrorAlert } from "./ProjectTransitionErrorAlert"; import { TabBackground } from "../ui/TabBackground"; -import { Z_LAYERS } from "../ui/zLayers"; import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { APP_BANNER_PRIORITY, AppBannerHost, useAppBanner } from "../ui/notice"; import type { @@ -41,7 +40,6 @@ import { } from "../../lib/zoom"; import { syncWindowsTitleBarOverlay } from "../../lib/windowControlsOverlay"; import { logRendererDebugEvent } from "../../lib/debugLog"; -import { holdLayoutSettle } from "../../lib/layoutSettle"; import { readLocalSyncStatus } from "../../lib/localSyncStatusReader"; import { cn } from "../ui/cn"; import { disposeTerminalRuntimesForProjectChange } from "../terminals/TerminalView"; @@ -188,29 +186,8 @@ function writeStoredProjectRoute(projectRoot: string, route: string): void { const FEEDBACK_PROGRESS_TOAST_ID = "ade-feedback-report-progress"; -/** - * Backstop for the tab-rail ResizeObserver hold. The rail's width transition is - * 200ms; this only has to outlast it, and exists so a `transitionend` that - * never arrives (reduced motion, an interrupted transition) cannot leave the - * panes' observers held. - */ -const RAIL_SETTLE_MAX_MS = 400; - -/** True when the element's `width` transition has a non-zero duration. */ -function hasRunningWidthTransition(element: HTMLElement): boolean { - const style = window.getComputedStyle(element); - const properties = style.transitionProperty.split(",").map((value) => value.trim()); - const durations = style.transitionDuration.split(",").map((value) => value.trim()); - return properties.some((property, index) => { - if (property !== "width" && property !== "all") return false; - const duration = durations[index % durations.length] ?? "0s"; - return Number.parseFloat(duration) > 0; - }); -} - export function AppShell({ children }: { children: React.ReactNode }) { const location = useLocation(); - const shellMainRef = useRef(null); const navigate = useNavigate(); useLaneEventToasts(navigate); usePrEventToasts(navigate); @@ -897,6 +874,15 @@ export function AppShell({ children }: { children: React.ReactNode }) { } }, []); + // Mod+1..5 and Mod+B act on the project surface, so they are off while the + // welcome page, Chats, or the account page is in front. + useProjectSidebarShortcuts({ + enabled: Boolean(project?.rootPath) && !showWelcome && !isPersonalChatsRoute && !isAccountRoute, + projectRoot: currentProjectRoot, + keybindings, + navigate, + }); + useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (!eventMatchesBinding(e, commandPaletteBinding)) return; @@ -924,29 +910,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { return tintMap[primaryTabPath(location.pathname)] ?? ""; }, [location.pathname]); - // The tab rail expands by animating its own width as a flex item, so
- // and every pane inside it really do get narrower frame by frame — content is - // pushed aside, exactly as on macOS. What we do not want is for - // react-resizable-panels to answer each of those frames with a forced - // synchronous layout and a re-render of every pane. Hold ResizeObserver - // delivery inside
for the length of the transition and let the panels - // settle once, on transitionend. Their flex-grow ratios keep the panes - // correctly proportioned in the meantime, for free, in the browser's layout. - const releaseRailHoldRef = useRef<(() => void) | null>(null); - const releasePanesOnRailSettle = useCallback((event?: React.TransitionEvent) => { - if (event && (event.propertyName !== "width" || event.target !== event.currentTarget)) return; - releaseRailHoldRef.current?.(); - releaseRailHoldRef.current = null; - }, []); - const holdPanesUntilRailSettles = useCallback((event: React.MouseEvent) => { - // Under `prefers-reduced-motion` the rail snaps instead of animating, so - // there is no run of frames to protect and no `transitionend` to release on. - if (!hasRunningWidthTransition(event.currentTarget)) return; - releaseRailHoldRef.current?.(); - releaseRailHoldRef.current = holdLayoutSettle(shellMainRef.current, RAIL_SETTLE_MAX_MS); - }, []); - useEffect(() => () => releasePanesOnRailSettle(), [releasePanesOnRailSettle]); - return (
- - -
+
void; }) { const [open, setOpen] = useState(false); - const [flipUp, setFlipUp] = useState(false); - const menuRef = useRef(null); + const triggerRef = useRef(null); - useEffect(() => { - if (!open) { - setFlipUp(false); - return; - } - const onDocClick = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setOpen(false); - // Dismissing the menu without acting must also stand down an armed - // delete confirmation. - if (confirmingDelete) onConfirmDismiss(); - } - }; - // Flip the menu above the trigger when it would overflow the viewport - // bottom; both anchor and menu live in the same offset-parent space. - const flip = () => { - const menu = menuRef.current?.querySelector("[data-row-menu-list]") as HTMLElement | null; - if (!menu) return; - const rect = menu.getBoundingClientRect(); - setFlipUp(window.innerHeight - rect.bottom < 8); - }; - document.addEventListener("mousedown", onDocClick); - requestAnimationFrame(flip); - return () => document.removeEventListener("mousedown", onDocClick); - }, [open, confirmingDelete, onConfirmDismiss]); + // Dismissing the menu without acting must also stand down an armed delete + // confirmation. + const dismiss = () => { + setOpen(false); + if (confirmingDelete) onConfirmDismiss(); + }; const itemClass = "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[11px] text-fg/70 transition-colors hover:bg-white/[0.06] hover:text-fg/95 disabled:opacity-40"; return ( -
event.stopPropagation()}> +
event.stopPropagation()}> - {open ? ( -
+ {/* Portalled above the fleet modal (z 9999): the modal's scrolling list + would cut off a menu opened near its bottom edge. */} + event.stopPropagation()} + > {finished && !entry.agent.archived ? ( -
- ) : null} +
); } diff --git a/apps/desktop/src/renderer/components/app/CursorCloudQuickViewButton.tsx b/apps/desktop/src/renderer/components/app/CursorCloudQuickViewButton.tsx index e7ceeac10d..45770f2343 100644 --- a/apps/desktop/src/renderer/components/app/CursorCloudQuickViewButton.tsx +++ b/apps/desktop/src/renderer/components/app/CursorCloudQuickViewButton.tsx @@ -251,7 +251,7 @@ export function CursorCloudQuickViewButton({ tile and inset, which made the glyph read smaller than Linear's mark in the same 20px control. */} {variant === "menu-row" ? : } - {variant !== "icon" ? Cursor Cloud : null} + {variant !== "icon" ? Cursor Cloud : null} {unreadFinished > 0 ? ( ({ StorageSection: stubSection(["sto vi.mock("../settings/SessionLifecycleSection", () => ({ SessionLifecycleSection: stubSection(["session-lifecycle"]) })); vi.mock("../settings/AdeUsageSection", () => ({ AdeUsageSection: stubSection(["ade-usage"]) })); vi.mock("../settings/RemoteContextBadge", () => ({ RemoteSettingsBanner: () => null })); +vi.mock("../account/AccountPage", () => ({ + AccountPage: ({ embedded }: { embedded?: boolean }) =>
{embedded ? "account embedded" : "account page"}
, +})); +vi.mock("./FeedbackReporterModal", () => ({ FeedbackReporterModal: () => null })); function LocationProbe() { const location = useLocation(); @@ -168,6 +172,33 @@ describe("SettingsPage", () => { }); }); + it("lists Account first in the Account group and renders the account page in place", async () => { + renderSettings("/settings?tab=account"); + + expect(await screen.findByRole("heading", { name: "Account" })).toBeTruthy(); + expect(screen.getByText("account embedded")).toBeTruthy(); + const nav = screen.getByRole("navigation", { name: "Settings sections" }); + const rows = [...nav.querySelectorAll("button")].map((button) => button.textContent); + expect(rows.slice(0, 3)).toEqual(["Account", "Secrets", "Usage"]); + }); + + it("carries feedback, help, zoom, and the identity row above the sections", async () => { + renderSettings("/settings?tab=appearance"); + await screen.findByRole("heading", { name: "Appearance" }); + + expect(screen.getByRole("button", { name: "Send feedback" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Help menu" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Zoom in" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Zoom out" })).toBeTruthy(); + + // Signed out, the identity row names the session state and still opens Account. + fireEvent.click(screen.getByRole("button", { name: /^Account, / })); + await waitFor(() => { + expect(screen.getByTestId("location").textContent).toBe("?tab=account"); + }); + expect(await screen.findByText("account embedded")).toBeTruthy(); + }); + it("says so when nothing on the current tab matches", async () => { renderSettings("/settings?tab=secrets"); await screen.findByRole("heading", { name: "Secrets" }); diff --git a/apps/desktop/src/renderer/components/app/SettingsPage.tsx b/apps/desktop/src/renderer/components/app/SettingsPage.tsx index 09dc82bc7c..3488dfd739 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -14,7 +14,9 @@ import { Palette, PlugsConnected, Pulse as PulseIcon, + UserCircle, } from "@phosphor-icons/react"; +import { AccountPage } from "../account/AccountPage"; import { ActivitySection } from "../settings/ActivitySection"; import { AppearanceSection } from "../settings/AppearanceSection"; import { ChatSection } from "../settings/ChatSection"; @@ -44,6 +46,7 @@ import { StorageSection } from "../settings/StorageSection"; import { RemoteSettingsBanner } from "../settings/RemoteContextBadge"; import { WebSettingsSection } from "../settings/WebScopePill"; import { Banner } from "../ui/notice"; +import { SettingsSidebarHeader } from "../settings/SettingsSidebarHeader"; import { SETTINGS_ENTRIES, availableSettingsTabs, @@ -65,6 +68,7 @@ import { isWebClientMode } from "../../lib/webClientMode"; import { useAppStore } from "../../state/appStore"; import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; import { COLORS, SANS_FONT, LABEL_STYLE } from "../lanes/laneDesignTokens"; +import { ProjectSidebarSlot, useHasProjectSidebar } from "./projectSidebar/ProjectSidebarSlot"; /** * The settings shell. Tabs, ordering, deep links, and search all resolve @@ -76,6 +80,7 @@ import { COLORS, SANS_FONT, LABEL_STYLE } from "../lanes/laneDesignTokens"; */ const TAB_ICONS: Record = { + account: UserCircle, general: GearSix, appearance: Palette, chat: ChatCircle, @@ -272,6 +277,17 @@ type TabSection = { * `TabContent` handles it directly. */ const TAB_SECTIONS: Partial> = { + account: [ + { + entryIds: ["account.profile"], + // The account page, without its own page chrome. + render: () => ( +
+ +
+ ), + }, + ], general: [ { entryIds: ["general.about", "general.auto-updates"], @@ -628,6 +644,7 @@ export function SettingsPage({ active = true }: { active?: boolean } = {}) { }, [trimmedQuery, matchesThisTab, section]); const repoGroupLabel = useAppStore((state) => state.project?.displayName) ?? "This repository"; + const hasProjectSidebar = useHasProjectSidebar(); const renderTabButton = (tab: SettingsTab) => { const Icon = TAB_ICONS[tab.id]; @@ -645,8 +662,8 @@ export function SettingsPage({ active = true }: { active?: boolean } = {}) { display: "flex", width: "100%", alignItems: "center", - gap: 10, - padding: "8px 10px", + gap: 9, + padding: "6px 10px", border: "none", background: isActive ? "var(--shell-sidebar-item-active-bg)" @@ -659,12 +676,11 @@ export function SettingsPage({ active = true }: { active?: boolean } = {}) { ? "var(--shell-sidebar-item-hover-fg)" : "var(--shell-sidebar-item-fg)", fontFamily: SANS_FONT, - fontSize: 11, - fontWeight: 600, - textTransform: "uppercase", - letterSpacing: "1px", + fontSize: 12.5, + fontWeight: isActive ? 600 : 500, + letterSpacing: "-0.01em", cursor: "pointer", - borderRadius: 8, + borderRadius: 7, textAlign: "left", transition: "background 120ms ease, color 120ms ease", }} @@ -675,61 +691,84 @@ export function SettingsPage({ active = true }: { active?: boolean } = {}) { ); }; + const sidebarHeader = ( + navigateToTab("account")} /> + ); + const activeTab = tabs.find((tab) => tab.id === section) ?? tabs.find((tab) => tab.id === defaultTab) ?? tabs[0]; const tabEntryCount = settingsEntriesForTab(section).length; const noMatchesHere = trimmedQuery.length > 0 && (matchesThisTab?.length ?? 0) === 0; + // Inside a project the section list lives in the project sidebar. Outside + // one (the hosted web client, tests) the page keeps its own column. + const sectionList = ( + <> + {SETTINGS_GROUPS.map((group) => { + const groupTabs = tabs.filter((tab) => tab.group === group.id); + // A group with nothing in it is not rendered. That is how the repo + // group disappears when Settings is opened outside a project: there + // is no repository to name and nothing filed under one. + if (!groupTabs.length) return null; + const label = group.label + ?? (group.id === "repo" ? repoGroupLabel : THIS_MACHINE_NAME); + return ( +
+
+ {label} +
+ {groupTabs.map((tab) => renderTabButton(tab))} +
+ ); + })} + + ); + return (
- + )}
{/* The remote banner contrasts a remote machine against this desktop. diff --git a/apps/desktop/src/renderer/components/app/TabNav.test.tsx b/apps/desktop/src/renderer/components/app/TabNav.test.tsx deleted file mode 100644 index 2fac4de7ba..0000000000 --- a/apps/desktop/src/renderer/components/app/TabNav.test.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/* @vitest-environment jsdom */ - -import React from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import { TabNav } from "./TabNav"; -import { useAppStore } from "../../state/appStore"; - -function resetStore() { - useAppStore.setState({ - project: { rootPath: "/Users/arul/ADE", name: "ADE" } as any, - projectBinding: null, - projectHydrated: true, - showWelcome: false, - selectedLaneId: "lane-1", - focusedSessionId: null, - lanes: [], - laneInspectorTabs: {}, - terminalAttention: { - runningCount: 0, - activeCount: 0, - needsAttentionCount: 0, - indicator: "none", - }, - workViewByProject: {}, - laneWorkViewByScope: {}, - smartTooltipsEnabled: true, - }); -} - -describe("TabNav", () => { - const originalAde = globalThis.window.ade; - - beforeEach(() => { - resetStore(); - window.localStorage.clear(); - Object.defineProperty(globalThis.window, "ade", { - configurable: true, - writable: true, - value: { - app: { - revealPath: async () => undefined, - getInfo: async () => ({ isPackaged: false }) as any, - openExternal: vi.fn().mockResolvedValue(undefined), - }, - }, - }); - }); - - afterEach(() => { - vi.useRealTimers(); - cleanup(); - window.localStorage.clear(); - Object.defineProperty(globalThis.window, "ade", { - configurable: true, - writable: true, - value: originalAde, - }); - }); - - it("links the sidebar account avatar to the account page", () => { - render( - - - , - ); - - expect(screen.getByRole("link", { name: "ADE account — signed out" }).getAttribute("href")).toBe( - "/account", - ); - expect(screen.getByText("Signed out")).toBeTruthy(); - }); - - it("keeps Chats available and active without a project", () => { - useAppStore.setState({ project: null, showWelcome: true } as any); - - render( - - - , - ); - - expect(screen.getByRole("link", { name: "Chats" }).getAttribute("data-active")).toBe("true"); - expect(screen.getByRole("link", { name: "Work" }).getAttribute("aria-disabled")).toBe("true"); - expect(screen.getByRole("link", { name: "PRs" }).getAttribute("aria-disabled")).toBe("true"); - }); - - it("places sidebar tooltips beside navigation rows", () => { - useAppStore.setState({ smartTooltipsEnabled: true }); - vi.useFakeTimers(); - - render( - - - , - ); - - const chatsLink = screen.getByRole("link", { name: "Chats" }); - const wrapper = chatsLink.parentElement; - if (!(wrapper instanceof HTMLElement)) throw new Error("sidebar tooltip wrapper is missing"); - fireEvent.mouseEnter(wrapper); - act(() => vi.advanceTimersByTime(320)); - - expect(document.querySelector('.ade-smart-tooltip[data-side="right"]')).toBeTruthy(); - }); - - it("does not steal Tab from sidebar rows that have documentation links", () => { - useAppStore.setState({ smartTooltipsEnabled: true }); - vi.useFakeTimers(); - - render( - - - , - ); - - const workLink = screen.getByRole("link", { name: "Work" }); - const wrapper = workLink.parentElement; - if (!(wrapper instanceof HTMLElement)) throw new Error("sidebar tooltip wrapper is missing"); - fireEvent.mouseEnter(wrapper); - act(() => vi.advanceTimersByTime(320)); - - const tooltip = document.querySelector(".ade-smart-tooltip"); - expect(tooltip?.getAttribute("role")).toBe("tooltip"); - expect(workLink.getAttribute("aria-haspopup")).toBeNull(); - expect(fireEvent.keyDown(wrapper, { key: "Tab" })).toBe(true); - }); - - it("falls back to a native title when detailed tooltips are disabled", () => { - useAppStore.setState({ smartTooltipsEnabled: false }); - - render( - - - , - ); - - expect(screen.getByRole("link", { name: "Chats" }).getAttribute("title")).toBe("Chats"); - }); - - it("keeps Activity a header control and a modal, never a nav tab", () => { - useAppStore.setState({ - project: null, - projectBinding: null, - showWelcome: true, - } as any); - - render( - - - , - ); - - // Deliberate: deleting this assertion is the quiet path to a tenth tab - // nobody agreed to. Activity lives in the header and opens as a modal. - expect(screen.queryByRole("link", { name: "Activity" })).toBeNull(); - expect(screen.queryByRole("link", { name: "Activity" })).toBeNull(); - }); - - it("reopens the active project's last settings location", () => { - window.localStorage.setItem( - "ade:project-route:local:/Users/arul/ADE", - "/settings?tab=appearance#chat-launch-clipboard", - ); - - render( - - - , - ); - - expect(screen.getByRole("link", { name: "Settings" }).getAttribute("href")).toBe( - "/settings?tab=appearance#chat-launch-clipboard", - ); - }); -}); diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx deleted file mode 100644 index a90b1cd982..0000000000 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ /dev/null @@ -1,449 +0,0 @@ -import React, { useCallback, useEffect, useState } from "react"; -import { NavLink, useLocation } from "react-router-dom"; -import { - GitBranch, - FileCode, - Terminal, - GitPullRequest, - Clock, - HourglassSimple, - Robot, - ChatCircleDots, - GearSix, -} from "@phosphor-icons/react"; -import { UserCircle } from "@phosphor-icons/react"; -import { cn } from "../ui/cn"; -import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition"; -import { useAppStore } from "../../state/appStore"; -import { revealLabel } from "../../lib/platform"; -import { isWebClientMode, WEB_CLIENT_TAB_PATHS } from "../../lib/webClientMode"; -import { - accountAvatarImage, - accountInitials, - accountSessionState, - accountSessionShortLabel, - providerTint, - useAccountStatus, -} from "../../lib/account"; -import { logRendererDebugEvent } from "../../lib/debugLog"; -import { docs } from "../../onboarding/docsLinks"; -import { SmartTooltip, type SmartTooltipContent } from "../ui/SmartTooltip"; -import type { GitHubStatus } from "../../../shared/types"; -import { readStoredPrsRoute } from "../prs/prsRouteState"; -import { readStoredProjectSettingsRoute } from "./projectRouteStorage"; - -type TabNavItem = { - to: string; - label: string; - icon: React.ElementType; - description: string; - docUrl?: string; - requiresProject?: boolean; - idleEffect?: string; - activeEffect?: string; -}; - -const mainItems: TabNavItem[] = [ - { - to: "/work", - label: "Work", - icon: Terminal, - description: "Chat with agents, launch CLI sessions, inspect shells, and use the right-side tool drawers.", - docUrl: docs.chatOverview, - }, - { - to: "/lanes", - label: "Lanes", - icon: GitBranch, - description: "Create, inspect, stack, rebase, and clean up isolated worktrees for parallel work.", - docUrl: docs.lanesOverview, - }, - { - to: "/files", - label: "Files", - icon: FileCode, - description: "Browse lane workspaces, inspect file changes, and open project files without leaving ADE.", - docUrl: docs.filesEditor, - }, - { - to: "/prs", - label: "PRs", - icon: GitPullRequest, - description: "Review ADE and GitHub pull requests, queues, integration proposals, checks, and merge readiness.", - docUrl: docs.prsOverview, - }, - { - to: "/cto", - label: "CTO", - icon: Robot, - description: "Chat with the persistent project CTO and manage its identity and settings.", - docUrl: docs.ctoOverview, - }, - { - to: "/history", - label: "History", - icon: HourglassSimple, - description: "Explore commit history, lane operations, branch links, and recent project movement.", - docUrl: docs.historyOverview, - }, - { - to: "/automations", - label: "Automations", - icon: Clock, - description: "Manage automation rules that trigger ADE work from events, schedules, and guarded actions.", - docUrl: docs.automationsOverview, - }, -]; - -const settingsItem: TabNavItem = { - to: "/settings", - label: "Settings", - icon: GearSix, - description: "Configure AI providers, GitHub, Linear, voice, lane behavior, templates, keybindings, and local project settings.", - docUrl: docs.settingsGeneral, -}; - -const chatsItem: TabNavItem = { - to: "/chats", - label: "Chats", - icon: ChatCircleDots, - description: "Chat with ADE agents without opening or linking a project.", - requiresProject: false, - idleEffect: "Opens projectless chats.", - activeEffect: "Already viewing chats.", -}; -const SIDEBAR_ICON_SIZE = 20; -const SIDEBAR_AVATAR_SIZE_CLASS = "h-5 w-5"; - -function tabNavTooltipEffect(args: { - hasActiveProject: boolean; - showWelcome: boolean; - isActive: boolean; - label: string; -}): string { - if (!args.hasActiveProject) return "Open or create a project first."; - if (args.showWelcome) return "Finish choosing a project before navigating."; - if (args.isActive) return "Already viewing this tab."; - return `Opens ${args.label}.`; -} - -function tabItemEffect( - it: TabNavItem, - args: { - hasActiveProject: boolean; - showWelcome: boolean; - isActive: boolean; - label: string; - }, -): string { - if (it.activeEffect && it.idleEffect) { - return args.isActive ? it.activeEffect : it.idleEffect; - } - return tabNavTooltipEffect(args); -} - -function tabNavTarget(to: string, prsRoute: string | null, settingsRoute: string | null): string { - if (to === "/prs") return prsRoute ?? to; - if (to === "/settings") return settingsRoute ?? to; - return to; -} - -function primaryTabPath(pathname: string): string { - if (pathname === "/chats" || pathname.startsWith("/chats/")) return "/chats"; - const match = mainItems.find((item) => pathname === item.to || pathname.startsWith(`${item.to}/`)); - if (match) return match.to; - return pathname === settingsItem.to || pathname.startsWith(`${settingsItem.to}/`) ? settingsItem.to : pathname; -} - -export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) { - const project = useAppStore((s) => s.project); - const projectBinding = useAppStore((s) => s.projectBinding); - const showWelcome = useAppStore((s) => s.showWelcome); - const terminalAttention = useAppStore((s) => s.terminalAttention); - const ctoAttention = useAppStore((s) => s.ctoAttention); - const ctoWaitingLabel = ctoAttention.since - ? `The CTO is waiting on you — since ${new Date(ctoAttention.since).toLocaleTimeString()}` - : "The CTO is waiting on you"; - const location = useLocation(); - const { status: accountStatus } = useAccountStatus(); - const activeProjectRoot = - projectBinding?.kind === "remote" ? projectBinding.rootPath : (project?.rootPath ?? null); - const activeProjectBindingKey = projectBinding?.key ?? (activeProjectRoot ? `local:${activeProjectRoot}` : null); - // Read this on each render because the sidebar stays mounted while the - // settings tab changes and the route memory is updated by the app shell. - const storedSettingsRoute = activeProjectBindingKey - ? readStoredProjectSettingsRoute(activeProjectBindingKey) - ?? (activeProjectRoot && activeProjectRoot !== activeProjectBindingKey - ? readStoredProjectSettingsRoute(activeProjectRoot) - : null) - : null; - const hasActiveProject = Boolean(activeProjectRoot); - const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); - const { ref: sidebarMenuRef, position: sidebarMenuPosition } = useClampedFixedPosition(contextMenu); - const [avatarBroken, setAvatarBroken] = useState(false); - const githubLogin = githubStatus?.userLogin || null; - const githubConnected = Boolean(githubStatus?.connected); - const avatarImage = accountAvatarImage(accountStatus, githubLogin); - const accountRingTint = providerTint(accountStatus, githubConnected); - // "Signed out" is only true for a real sign-out. An expired or unreadable - // session says something different, and the sidebar should not flatten them. - const accountSession = accountSessionState(accountStatus); - // A signed-in account always resolves to the "active" state, so the label for - // the current state is the right fallback in both branches — no literal. - const accountIdentityLabel = accountStatus.signedIn - ? accountStatus.name?.trim() || accountStatus.email?.trim() || githubLogin - : null; - const accountLabel = accountIdentityLabel || accountSessionShortLabel(accountSession); - const accountRouteActive = - location.pathname === "/account" || location.pathname.startsWith("/account/"); - const accountReturnTo = accountRouteActive - ? "/work" - : `${location.pathname}${location.search}${location.hash}`; - - useEffect(() => { - setAvatarBroken(false); - }, [githubLogin, avatarImage]); - - useEffect(() => { - if (!contextMenu) return; - const onPointerDown = () => setContextMenu(null); - window.addEventListener("pointerdown", onPointerDown); - return () => window.removeEventListener("pointerdown", onPointerDown); - }, [contextMenu]); - - const handleContextMenu = useCallback((event: React.MouseEvent) => { - event.preventDefault(); - setContextMenu({ x: event.clientX, y: event.clientY }); - }, []); - - const renderItem = (it: TabNavItem) => { - const onWelcomeLanding = showWelcome || !hasActiveProject; - const requiresProject = it.requiresProject !== false; - const isActive = requiresProject - ? !onWelcomeLanding && primaryTabPath(location.pathname) === it.to - : primaryTabPath(location.pathname) === it.to; - const isActiveAllowed = !requiresProject || (!showWelcome && hasActiveProject); - const navTarget = tabNavTarget(it.to, readStoredPrsRoute(activeProjectRoot), storedSettingsRoute); - // The Work tab's dot says "something needs you"; the tooltip says how many, - // so the board's Needs you column is readable without opening the tab. - const needsYouCount = - it.to === "/work" && isActiveAllowed ? terminalAttention.needsAttentionCount : 0; - const tooltip: SmartTooltipContent = { - label: it.label, - description: - needsYouCount > 0 - ? `${needsYouCount} need${needsYouCount === 1 ? "s" : ""} you. ${it.description}` - : it.description, - effect: tabItemEffect(it, { - hasActiveProject, - showWelcome, - isActive, - label: it.label, - }), - docUrl: it.docUrl, - }; - - const icon = ( - - - - {isActiveAllowed && it.to === "/work" && terminalAttention.indicator !== "none" ? ( - - ) : null} - {isActiveAllowed && it.to === "/cto" && ctoAttention.awaitingInput ? ( - - ) : null} - - - ); - - const row = isActiveAllowed ? ( - { - logRendererDebugEvent("renderer.tab_nav.click", { - projectRoot: activeProjectRoot, - from: location.pathname, - to: navTarget, - showWelcome, - hasActiveProject, - }); - }} - className="ade-shell-sidebar-item group relative flex w-full items-center transition-colors duration-100" - > - {isActive ?
: null} - {icon} - {it.label} - - ) : ( -
- {icon} - {it.label} -
- ); - - return ( - - {row} - - ); - }; - - // The hosted web client surfaces the tabs listed in `WEB_CLIENT_TAB_PATHS` — - // one authoritative list rather than an index into `mainItems`. Automations - // stays hidden there: it has no host-side actions, so it would be a dead nav - // entry. - const webMode = isWebClientMode(); - const toolItems = webMode - ? mainItems.slice(4).filter((it) => WEB_CLIENT_TAB_PATHS.has(it.to)) - : mainItems.slice(4); - const showSettings = !webMode || WEB_CLIENT_TAB_PATHS.has(settingsItem.to); - - return ( - <> - - - {/* Context menu */} - {/* Revealing a path is a native-shell action: the web adapter's - `app.revealPath` resolves to nothing, so the browser gets no menu. */} - {contextMenu && activeProjectRoot && !webMode ? ( -
e.stopPropagation()} - > - -
- ) : null} - - ); -} diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 0268786cfd..c77c648832 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -7,6 +7,7 @@ import { MemoryRouter } from "react-router-dom"; import { TopBar } from "./TopBar"; import { confirmDialog } from "../ui/dialog/confirm"; import { applyShellHeaderInset } from "../../lib/zoom"; +import { resetAppZoomCacheForTests } from "../../lib/appZoom"; import { openConnectionsPanel } from "../../lib/connectionsPanel"; import { useAppStore } from "../../state/appStore"; import { @@ -60,14 +61,6 @@ vi.mock("./AutoUpdateControl", () => ({ AutoUpdateControl: () => null, })); -vi.mock("./FeedbackReporterModal", () => ({ - FeedbackReporterModal: () => null, -})); - -vi.mock("../onboarding/HelpMenu", () => ({ - HelpMenu: () => null, -})); - vi.mock("../usage/HeaderUsageControl", () => ({ HeaderUsageControl: () => null, })); @@ -426,6 +419,7 @@ describe("TopBar", () => { globalThis.window.__adeWebClient = originalWebClientMode; } resetActivityStoreForTests(); + resetAppZoomCacheForTests(); publishAccountStatus(SIGNED_OUT_ACCOUNT); }); @@ -488,6 +482,52 @@ describe("TopBar", () => { expect(onNavigate).not.toHaveBeenCalledWith("/activity"); }); + it("shows the sidebar toggle only while a project surface is on screen", () => { + const root = "/Users/arul/ADE"; + useAppStore.setState({ + project: { rootPath: root, name: "ADE" }, + projectHydrated: true, + showWelcome: false, + isNewTabOpen: false, + } as any); + window.localStorage.removeItem("ade.shell.projectSidebar.hidden"); + + const { rerender } = render(); + const toggle = screen.getByRole("button", { name: "Toggle sidebar" }); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + + rerender(); + expect(screen.queryByRole("button", { name: "Toggle sidebar" })).toBeNull(); + + act(() => { + useAppStore.setState({ showWelcome: true } as any); + }); + rerender(); + expect(screen.queryByRole("button", { name: "Toggle sidebar" })).toBeNull(); + }); + + // Account, Settings, feedback, help, and zoom moved to the project sidebar + // (the footer cog and the settings sidebar header). + it("keeps account, settings, feedback, help, and zoom out of the top bar", () => { + useAppStore.setState({ + project: { rootPath: "/Users/arul/ADE", name: "ADE" }, + projectBinding: null, + projectHydrated: true, + showWelcome: false, + } as any); + + render(); + + expect(screen.queryByRole("button", { name: /^Account/ })).toBeNull(); + expect(screen.queryByRole("button", { name: "Report bug or suggest feature" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Zoom in" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Zoom out" })).toBeNull(); + }); + it("shows connections before a project is open without immediate polling", async () => { useAppStore.setState({ project: null, projectHydrated: true, showWelcome: true } as any); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 21df867b40..482b2ccfdd 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -15,7 +15,6 @@ import { Folder, FolderOpen, Plus, - Minus, Plugs, Trash, UploadSimple, @@ -25,29 +24,25 @@ import { import { Dialog as AppDialog } from "../ui/dialog/Dialog"; import { useAppStore } from "../../state/appStore"; +import { WorkToolPickerBackdrop } from "../terminals/WorkToolPickerBackdrop"; import { useGithubProjectRemote } from "../../lib/useGithubProjectRemote"; import { isWebClientMode } from "../../lib/webClientMode"; import { remoteProjectBindingKey } from "../../../shared/projectIdentity"; import { rememberProjectOriginSummaries } from "../lanes/laneMachines"; -import { - ZOOM_LEVEL_KEY, - MIN_ZOOM_LEVEL, - MAX_ZOOM_LEVEL, - DEFAULT_ZOOM, - ZOOM_STEP, - displayZoomToLevel, - getStoredZoomLevel, - applyShellHeaderInset, -} from "../../lib/zoom"; +import { resetAppZoom, zoomAppIn, zoomAppOut } from "../../lib/appZoom"; import { consumeAppMenuCommand } from "../../lib/appMenuCommands"; import { consumeAppZoomCommand } from "../../lib/appZoomCommands"; -import { syncWindowsTitleBarOverlay } from "../../lib/windowControlsOverlay"; import { cn } from "../ui/cn"; import { Banner } from "../ui/notice/Banner"; import { readStoredProjectRoute, removeStoredProjectRoute, } from "./projectRouteStorage"; +import { ProjectSidebarToggle } from "./projectSidebar/ProjectSidebarToggle"; +import { + PROJECT_SIDEBAR_TOGGLE_KEYBINDING, + projectSidebarShortcutLabel, +} from "./projectSidebar/projectSidebarTabs"; import { activeMachineForGroup, groupProjectTabs, @@ -59,7 +54,7 @@ import { deriveIconAccentColor } from "../../lib/iconAccent"; import { SmartTooltip } from "../ui/SmartTooltip"; import { ViewportOverlayHost } from "../ui/ViewportOverlayHost"; import { confirmDialog } from "../ui/dialog/confirm"; -import { isMac, modifierKeyLabel } from "../../lib/platform"; +import { isMac } from "../../lib/platform"; import type { ProjectIcon, OpenProjectBinding, @@ -72,9 +67,7 @@ import type { } from "../../../shared/types"; import { AutoUpdateControl } from "./AutoUpdateControl"; import { ChannelBadge } from "./ChannelBadge"; -import { FeedbackReporterModal } from "./FeedbackReporterModal"; import { HeaderSheet } from "./HeaderSheet"; -import { HelpMenu } from "../onboarding/HelpMenu"; import { LinearQuickViewButton } from "./LinearQuickViewButton"; import { CursorCloudQuickViewButton } from "./CursorCloudQuickViewButton"; import { PublishToGitHubDialog } from "../projects/PublishToGitHubDialog"; @@ -924,6 +917,7 @@ export function TopBar({ onOpenActivityPane?: () => void; } = {}) { const project = useAppStore((s) => s.project); + const theme = useAppStore((s) => s.theme); const hasProject = Boolean(project?.rootPath); const projectBinding = useAppStore((s) => s.projectBinding); const projectHydrated = useAppStore((s) => s.projectHydrated); @@ -953,7 +947,6 @@ export function TopBar({ // In the browser web client there are no OS windows to open/close and no // desktop auto-updater; hide those controls so web shows no dead buttons. const webMode = isWebClientMode(); - const [zoom, setZoom] = useState(getStoredZoomLevel); const [syncSnapshot, setSyncSnapshot] = useState( null, ); @@ -969,7 +962,6 @@ export function TopBar({ }, [], ); - const [feedbackOpen, setFeedbackOpen] = useState(false); const [publishOpen, setPublishOpen] = useState(false); const openProjectTabRoots = useAppStore((s) => s.openProjectTabRoots); const setOpenProjectTabRoots = useAppStore((s) => s.setOpenProjectTabRoots); @@ -1017,6 +1009,18 @@ export function TopBar({ Boolean(project?.rootPath) && !remoteBinding; const resourceUsage = useResourcePressureUsage(workspaceProjectOpen); + // The sidebar toggle only shows while a project surface is on screen: not on + // the welcome page, a new tab, Chats, or the account page. + const projectSurfaceVisible = + projectHydrated === true && + showWelcome !== true && + isNewTabOpen !== true && + Boolean(project?.rootPath) && + !personalChatsRouteActive && + !accountRouteActive && + !hubRouteActive; + const keybindings = useAppStore((s) => s.keybindings); + const sidebarToggleShortcut = projectSidebarShortcutLabel(keybindings, PROJECT_SIDEBAR_TOGGLE_KEYBINDING); const projectRootForRemote = workspaceProjectOpen ? (project?.rootPath ?? null) @@ -1069,28 +1073,10 @@ export function TopBar({ openRemoteProjectTabsRef.current = openRemoteProjectTabs; }, [openRemoteProjectTabs]); - // Mirrors the latest applied zoom so menu/keyboard commands compound off the - // current level. Updated synchronously inside applyZoom (not via a passive - // effect) so back-to-back commands before the next render don't reuse a stale - // value and collapse multiple steps into one. - const zoomRef = useRef(zoom); - const applyZoom = useCallback((pct: number) => { - const clamped = Math.max(MIN_ZOOM_LEVEL, Math.min(MAX_ZOOM_LEVEL, pct)); - window.ade.zoom.setLevel(displayZoomToLevel(clamped)); - localStorage.setItem(ZOOM_LEVEL_KEY, String(clamped)); - applyShellHeaderInset(clamped); - // Windows twin of the traffic-light inset: the native caption strip is - // sized in DIP and does not follow renderer zoom on its own. - syncWindowsTitleBarOverlay({ displayZoom: clamped }); - zoomRef.current = clamped; - setZoom(clamped); - }, []); - - const zoomIn = useCallback(() => applyZoom(zoom + ZOOM_STEP), [applyZoom, zoom]); - const zoomOut = useCallback(() => applyZoom(zoom - ZOOM_STEP), [applyZoom, zoom]); - - // Route native View-menu (and keyboard) zoom through the same applyZoom path - // so display %, persistence, and the macOS traffic-light inset stay in sync. + // Route native View-menu (and keyboard) zoom through the shared zoom store, + // the same path the settings sidebar's zoom buttons use, so the display %, + // persistence, and the traffic-light inset stay in sync. The bar has no zoom + // buttons of its own; it listens here because it is always mounted. useEffect(() => { const onCommand = window.ade?.zoom?.onCommand; if (typeof onCommand !== "function") return; @@ -1100,11 +1086,11 @@ export function TopBar({ // content — the built-in browser's page zoom — has to be offered the // command here. It declines unless it actually has focus. if (consumeAppZoomCommand(command)) return; - if (command === "in") applyZoom(zoomRef.current + ZOOM_STEP); - else if (command === "out") applyZoom(zoomRef.current - ZOOM_STEP); - else applyZoom(DEFAULT_ZOOM); + if (command === "in") zoomAppIn(); + else if (command === "out") zoomAppOut(); + else resetAppZoom(); }); - }, [applyZoom]); + }, []); /** * ⌘F and ⌘W, offered to the pane that has the keyboard before the app @@ -2266,9 +2252,17 @@ export function TopBar({ return (
+ {/* The top bar's part of the window gradient. It is one field with the + welcome screen and the new chat pane, so where they meet there is + no seam. */} +
+ +
+ {projectSurfaceVisible ? : null} + {/* Branding */} ) : null} - {/* Trailing controls: activity · status · updates · utility cluster. + {/* Trailing controls: activity · status · updates. The group must be able to shrink: the header reserves room for the native window controls (macOS traffic lights at the start, Windows caption buttons at the end) with padding, and a shrink-0 group would simply overflow that padding at narrow widths and slide back under - them. The status/update strip therefore clips first so the utility - cluster — feedback, help, zoom — always stays inside the reservation. */} -
-
- {/* Account-wide Activity — the one place every machine's work surfaces, - reachable from every tab and project without a nav detour. */} - - - {/* App-global voice capture — visible from any tab while recording. */} - - - - - -
- {renderHeaderStatusControls()} -
+ them, so it clips instead. Feedback, help, and zoom live in the + settings sidebar. */} +
+ {/* Account-wide Activity — the one place every machine's work surfaces, + reachable from every tab and project without a nav detour. */} + - - {(closeMenu) => renderHeaderStatusControls({ menuLayout: true, onActivate: closeMenu })} - + {/* App-global voice capture — visible from any tab while recording. */} + - {!webMode ? : null} + + + +
+ {renderHeaderStatusControls()}
-
- - - - - + {(closeMenu) => renderHeaderStatusControls({ menuLayout: true, onActivate: closeMenu })} + -
- - - - - {zoom}% - - - - -
-
+ {!webMode ? : null}
{/* Overlay panels & modals — kept outside the gap-6 wrapper so they @@ -2936,11 +2850,6 @@ export function TopBar({ /> - - 0; + const filterButtonRef = useRef(null); return (
@@ -140,6 +142,7 @@ export function WorkFilterBar({ ))} + {/* Portalled: the palette clips its content, so a short result list + would cut the menu off. */} {filterMenuKey ? ( -
onMenuKeyChange(null)} + placement="bottom-end" + zIndex={130} + remeasureKey={filterMenuKey} role="menu" aria-label={ filterMenuKey === "choose" ? "Work filter facets" : `${WORK_FILTER_LABELS[filterMenuKey]} filter values` } - className="absolute right-0 top-full z-20 mt-1 max-h-64 min-w-44 overflow-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-popup-bg)] p-1 shadow-xl" + className="max-h-64 min-w-44 overflow-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-popup-bg)] p-1 shadow-xl" > {filterMenuKey === "choose" ? ( <> @@ -251,7 +262,7 @@ export function WorkFilterBar({
)} -
+ ) : null} Search: {query}
diff --git a/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebar.test.tsx b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebar.test.tsx new file mode 100644 index 0000000000..c66e79fc07 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebar.test.tsx @@ -0,0 +1,175 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { useAppStore } from "../../../state/appStore"; +import { ProjectSidebar } from "./ProjectSidebar"; +import { + ProjectSidebarHold, + ProjectSidebarSlot, + ProjectSidebarSlotProvider, + useProjectSidebarSlotTarget, +} from "./ProjectSidebarSlot"; +import { setProjectSidebarHidden } from "./projectSidebarPrefs"; +import { + lastSettingsRoute, + rememberProjectRoute, + resetSettingsReturnRoutesForTest, + settingsReturnRoute, +} from "./settingsReturnRoute"; + +const ROOT = "/Users/arul/ADE"; + +function LocationProbe() { + const location = useLocation(); + return
{`${location.pathname}${location.search}${location.hash}`}
; +} + +function renderSidebar(route: string, heldRoute: string | null = null) { + return render( + + + + } /> + + , + ); +} + +describe("ProjectSidebar footer", () => { + beforeEach(() => { + useAppStore.setState({ project: { rootPath: ROOT, name: "ADE" }, projectBinding: null } as any); + }); + + afterEach(() => { + cleanup(); + setProjectSidebarHidden(false); + resetSettingsReturnRoutesForTest(); + window.localStorage.clear(); + delete (window as { __adeWebClient?: boolean }).__adeWebClient; + }); + + it("keeps CTO and History, and puts a settings cog at the end", () => { + renderSidebar("/work"); + const footerButtons = [...document.querySelectorAll(".ade-project-sidebar-footer button")]; + expect(footerButtons.map((button) => button.getAttribute("aria-label") ?? button.textContent)).toEqual([ + "CTO", + "History", + "Settings", + ]); + expect(screen.getByRole("button", { name: "Settings" }).getAttribute("aria-current")).toBeNull(); + }); + + it("opens Settings from the cog, and marks it active on the settings route", () => { + renderSidebar("/work"); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(screen.getByTestId("location").textContent).toBe("/settings"); + + cleanup(); + renderSidebar("/settings?tab=account"); + expect(screen.getByRole("button", { name: "Settings" }).getAttribute("aria-current")).toBe("page"); + }); + + it("reopens the project's stored settings page", () => { + window.localStorage.setItem(`ade:project-route:local:${ROOT}`, "/settings?tab=appearance#theme"); + renderSidebar("/work"); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(screen.getByTestId("location").textContent).toBe("/settings?tab=appearance#theme"); + }); + + it("returns to where you were when the cog is clicked again", () => { + renderSidebar("/prs?pr=1292"); + cleanup(); + renderSidebar("/settings?tab=account"); + expect(screen.getByRole("button", { name: "Settings" }).getAttribute("title")).toBe("Back"); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(screen.getByTestId("location").textContent).toBe("/prs?pr=1292"); + }); + + it("reopens the last settings section used in this session before the stored one", () => { + window.localStorage.setItem(`ade:project-route:local:${ROOT}`, "/settings?tab=appearance#theme"); + renderSidebar("/settings?tab=providers"); + cleanup(); + renderSidebar("/lanes"); + fireEvent.click(screen.getByRole("button", { name: "Settings" })); + expect(screen.getByTestId("location").textContent).toBe("/settings?tab=providers"); + }); + + it("keeps the page under CTO selected and turns the footer into one Back button", () => { + renderSidebar("/cto", "/prs?pr=1292"); + expect(screen.getByRole("button", { name: "PRs" }).getAttribute("aria-current")).toBe("page"); + // The whole footer row is the way back; CTO and History leave it meanwhile. + expect(screen.queryByRole("button", { name: "CTO" })).toBeNull(); + expect(screen.queryByRole("button", { name: "History" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Back" })); + expect(screen.getByTestId("location").textContent).toBe("/prs?pr=1292"); + }); + + it("shows no Back chip when nothing is held", () => { + renderSidebar("/cto"); + expect(screen.queryByRole("button", { name: "Back" })).toBeNull(); + }); +}); + +function SlotHost({ children }: { children: React.ReactNode }) { + const setTarget = useProjectSidebarSlotTarget(); + return
{children}
; +} + +describe("settings return routes", () => { + afterEach(() => { + resetSettingsReturnRoutesForTest(); + }); + + it("remembers the page you left and the settings section you opened", () => { + rememberProjectRoute("local:/repo", "/prs?pr=12"); + rememberProjectRoute("local:/repo", "/settings?tab=account"); + rememberProjectRoute("local:/other", "/files"); + + expect(settingsReturnRoute("local:/repo")).toBe("/prs?pr=12"); + expect(lastSettingsRoute("local:/repo")).toBe("/settings?tab=account"); + expect(settingsReturnRoute("local:/other")).toBe("/files"); + expect(settingsReturnRoute(null)).toBe("/work"); + expect(lastSettingsRoute("local:/missing")).toBeNull(); + }); + + it("keeps the page under Settings when CTO or History opens on top", () => { + rememberProjectRoute("local:/repo", "/prs?pr=12"); + rememberProjectRoute("local:/repo", "/settings?tab=account"); + rememberProjectRoute("local:/repo", "/cto"); + rememberProjectRoute("local:/repo", "/history"); + + expect(settingsReturnRoute("local:/repo")).toBe("/prs?pr=12"); + expect(lastSettingsRoute("local:/repo")).toBe("/settings?tab=account"); + }); +}); + +describe("ProjectSidebarSlot hold", () => { + afterEach(() => { + cleanup(); + }); + + it("hides the held list while another slot is active", () => { + render( + + + + +
Held list
+
+
+ +
Settings sections
+
+
+
, + ); + + const held = screen.getByText("Held list").parentElement; + const settings = screen.getByText("Settings sections").parentElement; + expect(held?.hidden).toBe(true); + expect(settings?.hidden).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebar.tsx b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebar.tsx new file mode 100644 index 0000000000..5f2c0b17e2 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebar.tsx @@ -0,0 +1,248 @@ +import React, { startTransition, useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { ArrowLeft, GearSix } from "@phosphor-icons/react"; +import { cn } from "../../ui/cn"; +import { useAppStore } from "../../../state/appStore"; +import { isWebClientMode, WEB_CLIENT_TAB_PATHS } from "../../../lib/webClientMode"; +import { readStoredProjectSettingsRoute } from "../projectRouteStorage"; +import { + commitProjectSidebarWidth, + getProjectSidebarPrefs, + setProjectSidebarWidth, + useProjectSidebarPrefs, +} from "./projectSidebarPrefs"; +import { useProjectSidebarSlotTarget } from "./ProjectSidebarSlot"; +import { lastSettingsRoute, rememberProjectRoute, settingsReturnRoute } from "./settingsReturnRoute"; +import { + PROJECT_SIDEBAR_FOOTER_TABS, + PROJECT_SIDEBAR_TABS, + projectSidebarShortcutLabel, + projectSidebarTabForPath, + projectSidebarTabTarget, + visibleProjectSidebarTabs, +} from "./projectSidebarTabs"; + +export { projectSidebarTabForPath }; + +/** + * `heldRoute` is set while CTO or History is open: the page you came from, + * which still owns the sidebar list. Its tab stays selected, and the footer + * shows a Back chip that returns to it. + */ +export function ProjectSidebar({ route, heldRoute = null }: { route: string; heldRoute?: string | null }) { + const navigate = useNavigate(); + const { width, hidden } = useProjectSidebarPrefs(); + const setSlotTarget = useProjectSidebarSlotTarget(); + // Select scalars only: the sidebar sits beside every tab page, and a wide + // selector here would re-render it on every unrelated store write. + const projectRoot = useAppStore((s) => (s.projectBinding?.kind === "remote" ? s.projectBinding.rootPath : s.project?.rootPath ?? null)); + const bindingKey = useAppStore((s) => s.projectBinding?.key ?? null); + const workIndicator = useAppStore((s) => s.terminalAttention.indicator); + const needsYouCount = useAppStore((s) => s.terminalAttention.needsAttentionCount); + const ctoAwaitingInput = useAppStore((s) => s.ctoAttention.awaitingInput); + const keybindings = useAppStore((s) => s.keybindings); + const pathname = route.split(/[?#]/, 1)[0] || "/work"; + const activeTab = projectSidebarTabForPath(pathname); + const heldTab = heldRoute ? projectSidebarTabForPath(heldRoute.split(/[?#]/, 1)[0] || "/work") : null; + const settingsActive = pathname === "/settings" || pathname.startsWith("/settings/"); + const settingsVisible = !isWebClientMode() || WEB_CLIENT_TAB_PATHS.has("/settings"); + const routeKey = bindingKey ?? (projectRoot ? `local:${projectRoot}` : null); + + // The tab a click asked for, shown before the route commits. The tab strip + // starts its animation on the click, and the new page mounts after it. + const [pendingTab, setPendingTab] = useState(null); + const shownTab = pendingTab ?? heldTab ?? activeTab; + useEffect(() => { + setPendingTab(null); + }, [route]); + + useEffect(() => { + rememberProjectRoute(routeKey, route); + }, [route, routeKey]); + + const goTo = useCallback( + (target: string) => { + // Let the tab strip paint its first frame, then mount the page as a + // transition so React yields to the animation while it renders. + window.requestAnimationFrame(() => { + startTransition(() => { + void navigate(target); + }); + }); + }, + [navigate], + ); + + const openTab = useCallback( + (to: string) => { + setPendingTab(to); + // The held tab returns to exactly where you were on it. + goTo(heldRoute && heldTab === to ? heldRoute : projectSidebarTabTarget(to, projectRoot)); + }, + [goTo, heldRoute, heldTab, projectRoot], + ); + + const openSettings = useCallback(() => { + // The cog is a toggle: from Settings it returns to where you were. + if (settingsActive) { + navigate(settingsReturnRoute(routeKey)); + return; + } + // Otherwise it reopens the last Settings section of this project. + const stored = lastSettingsRoute(routeKey) + ?? (routeKey ? readStoredProjectSettingsRoute(routeKey) : null) + ?? (projectRoot && projectRoot !== routeKey ? readStoredProjectSettingsRoute(projectRoot) : null); + navigate(stored ?? "/settings"); + }, [navigate, projectRoot, routeKey, settingsActive]); + + if (hidden) return null; + + return ( + + ); +} + +function ProjectSidebarResizeHandle() { + const dragRef = useRef<{ startX: number; startWidth: number } | null>(null); + + const onPointerDown = useCallback((event: React.PointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { startX: event.clientX, startWidth: getProjectSidebarPrefs().width }; + document.body.classList.add("ade-project-sidebar-resizing"); + }, []); + + const onPointerMove = useCallback((event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + setProjectSidebarWidth(drag.startWidth + event.clientX - drag.startX); + }, []); + + const endDrag = useCallback((event: React.PointerEvent) => { + if (!dragRef.current) return; + dragRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + document.body.classList.remove("ade-project-sidebar-resizing"); + commitProjectSidebarWidth(); + }, []); + + return ( +
+ ); +} diff --git a/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebarSlot.tsx b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebarSlot.tsx new file mode 100644 index 0000000000..139738fa09 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebarSlot.tsx @@ -0,0 +1,108 @@ +import React, { createContext, useContext, useLayoutEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; + +/** + * The project sidebar owns an empty body element. Each tab page renders its + * list into that element through `ProjectSidebarSlot`, a portal. The list + * stays in the page's React tree, so it keeps the page's state, context and + * callbacks; only its DOM moves into the sidebar. + */ + +type ProjectSidebarSlotContextValue = { + slotElement: HTMLElement | null; + setSlotElement: (element: HTMLElement | null) => void; + /** Slots that passed `active`. A held list yields while this is non-zero. */ + activeClaimCount: number; + claimSidebar: () => () => void; +}; + +const ProjectSidebarSlotContext = createContext(null); + +export function ProjectSidebarSlotProvider({ children }: { children: React.ReactNode }) { + const [slotElement, setSlotElement] = useState(null); + const [activeClaimCount, setActiveClaimCount] = useState(0); + const claimSidebar = useMemo(() => { + return () => { + setActiveClaimCount((count) => count + 1); + return () => setActiveClaimCount((count) => count - 1); + }; + }, []); + const value = useMemo( + () => ({ slotElement, setSlotElement, activeClaimCount, claimSidebar }), + [slotElement, activeClaimCount, claimSidebar], + ); + return {children}; +} + +/** Ref callback for the sidebar body that receives the portals. */ +export function useProjectSidebarSlotTarget(): (element: HTMLElement | null) => void { + return useContext(ProjectSidebarSlotContext)?.setSlotElement ?? noopSetSlot; +} + +/** True when a project sidebar exists above this component. */ +export function useHasProjectSidebar(): boolean { + return useContext(ProjectSidebarSlotContext) != null; +} + +function noopSetSlot(): void {} + +/** + * CTO and History open over the page you were on. That page stays mounted + * behind them, inactive, and this keeps its sidebar list on screen so the + * sidebar looks exactly as you left it. + */ +const ProjectSidebarHoldContext = createContext(false); + +export function ProjectSidebarHold({ held, children }: { held: boolean; children: React.ReactNode }) { + return {children}; +} + +/** + * Renders `children` in the project sidebar body. + * + * Each slot gets its own container in the body. The container shows only + * while `active` is true, so a page that stays mounted while hidden (Work) + * keeps its list mounted too, and a return to that tab does not remount it. + * Only the page that is on screen may pass `active`. A page held under CTO + * or History (`ProjectSidebarHold`) shows its list while inactive, and hides + * it again while another slot is active (CTO settings takes the sidebar). + * + * Outside a project sidebar (tests, the Work tools pane) nothing renders, so a + * page must keep its own fallback column when `useHasProjectSidebar()` is false. + */ +export function ProjectSidebarSlot({ active, children }: { active: boolean; children: React.ReactNode }) { + const slotContext = useContext(ProjectSidebarSlotContext); + const slotElement = slotContext?.slotElement ?? null; + const claimSidebar = slotContext?.claimSidebar; + const held = useContext(ProjectSidebarHoldContext); + const claimed = (slotContext?.activeClaimCount ?? 0) > 0; + const shown = active || (held && !claimed); + + useLayoutEffect(() => { + if (!active || !claimSidebar) return; + return claimSidebar(); + }, [active, claimSidebar]); + const [container, setContainer] = useState(null); + + useLayoutEffect(() => { + if (!slotElement) return; + const element = document.createElement("div"); + element.className = "ade-project-sidebar-slot"; + slotElement.appendChild(element); + setContainer(element); + return () => { + element.remove(); + setContainer(null); + }; + }, [slotElement]); + + useLayoutEffect(() => { + if (!container) return; + container.hidden = !shown; + if (shown) container.removeAttribute("inert"); + else container.setAttribute("inert", ""); + }, [shown, container]); + + if (!container) return null; + return createPortal(children, container); +} diff --git a/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebarToggle.tsx b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebarToggle.tsx new file mode 100644 index 0000000000..b8195e522a --- /dev/null +++ b/apps/desktop/src/renderer/components/app/projectSidebar/ProjectSidebarToggle.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { SidebarSimple } from "@phosphor-icons/react"; +import { SmartTooltip } from "../../ui/SmartTooltip"; +import { toggleProjectSidebarHidden, useProjectSidebarHidden } from "./projectSidebarPrefs"; + +/** + * The top bar's sidebar toggle. It lives in its own component so only this + * button, not the whole top bar, re-renders while the sidebar is resized. + */ +export function ProjectSidebarToggle({ shortcut }: { shortcut: string | null }) { + const hidden = useProjectSidebarHidden(); + return ( + + ); +} diff --git a/apps/desktop/src/renderer/components/app/projectSidebar/projectSidebarPrefs.ts b/apps/desktop/src/renderer/components/app/projectSidebar/projectSidebarPrefs.ts new file mode 100644 index 0000000000..1324320ebd --- /dev/null +++ b/apps/desktop/src/renderer/components/app/projectSidebar/projectSidebarPrefs.ts @@ -0,0 +1,106 @@ +import { useSyncExternalStore } from "react"; + +/** + * App-wide state of the project sidebar: one width for every tab, and whether + * the sidebar is hidden. The top bar toggles it and sits above the per-project + * store, so this lives in a small module store and not in `appStore`. + */ + +export const PROJECT_SIDEBAR_DEFAULT_WIDTH = 300; +// Four icon tabs plus the open "Automations" tab need about 258px. +export const PROJECT_SIDEBAR_MIN_WIDTH = 260; +export const PROJECT_SIDEBAR_MAX_WIDTH = 440; + +const WIDTH_KEY = "ade.shell.projectSidebar.width"; +const HIDDEN_KEY = "ade.shell.projectSidebar.hidden"; + +export type ProjectSidebarPrefs = { + width: number; + hidden: boolean; +}; + +export function clampProjectSidebarWidth(width: number): number { + if (!Number.isFinite(width)) return PROJECT_SIDEBAR_DEFAULT_WIDTH; + return Math.round(Math.min(PROJECT_SIDEBAR_MAX_WIDTH, Math.max(PROJECT_SIDEBAR_MIN_WIDTH, width))); +} + +function readStorage(key: string): string | null { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} + +function writeStorage(key: string, value: string): void { + try { + window.localStorage.setItem(key, value); + } catch { + // Storage can be unavailable (private mode, quota). The in-memory value still applies. + } +} + +function readInitialPrefs(): ProjectSidebarPrefs { + const rawWidth = readStorage(WIDTH_KEY); + return { + width: rawWidth == null ? PROJECT_SIDEBAR_DEFAULT_WIDTH : clampProjectSidebarWidth(Number(rawWidth)), + hidden: readStorage(HIDDEN_KEY) === "true", + }; +} + +let prefs: ProjectSidebarPrefs | null = null; +const listeners = new Set<() => void>(); + +function current(): ProjectSidebarPrefs { + if (!prefs) prefs = readInitialPrefs(); + return prefs; +} + +function update(next: Partial): void { + const merged = { ...current(), ...next }; + if (merged.width === current().width && merged.hidden === current().hidden) return; + prefs = merged; + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getProjectSidebarPrefs(): ProjectSidebarPrefs { + return current(); +} + +/** Sets the width while dragging. Call `commitProjectSidebarWidth` on release to persist it. */ +export function setProjectSidebarWidth(width: number): void { + update({ width: clampProjectSidebarWidth(width) }); +} + +export function commitProjectSidebarWidth(): void { + writeStorage(WIDTH_KEY, String(current().width)); +} + +export function setProjectSidebarHidden(hidden: boolean): void { + update({ hidden }); + writeStorage(HIDDEN_KEY, String(hidden)); +} + +export function toggleProjectSidebarHidden(): void { + setProjectSidebarHidden(!current().hidden); +} + +export function useProjectSidebarPrefs(): ProjectSidebarPrefs { + return useSyncExternalStore(subscribe, current, current); +} + +function currentHidden(): boolean { + return current().hidden; +} + +/** Reads only `hidden`, so a width drag does not re-render the caller. */ +export function useProjectSidebarHidden(): boolean { + return useSyncExternalStore(subscribe, currentHidden, currentHidden); +} diff --git a/apps/desktop/src/renderer/components/app/projectSidebar/projectSidebarTabs.ts b/apps/desktop/src/renderer/components/app/projectSidebar/projectSidebarTabs.ts new file mode 100644 index 0000000000..db51b934b7 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/projectSidebar/projectSidebarTabs.ts @@ -0,0 +1,90 @@ +import type React from "react"; +import { + Clock, + FileCode, + GitBranch, + GitPullRequest, + HourglassSimple, + Robot, + Terminal, +} from "@phosphor-icons/react"; +import type { KeybindingsSnapshot } from "../../../../shared/types"; +import { getEffectiveBinding } from "../../../lib/keybindings"; +import { isMac } from "../../../lib/platform"; +import { isWebClientMode, WEB_CLIENT_TAB_PATHS } from "../../../lib/webClientMode"; +import { readStoredPrsRoute } from "../../prs/prsRouteState"; + +export type ProjectSidebarTab = { + to: string; + label: string; + icon: React.ElementType; + /** Keybinding id and default chord, when the tab has a shortcut. */ + keybinding?: { id: string; fallback: string }; +}; + +export const PROJECT_SIDEBAR_TABS: ProjectSidebarTab[] = [ + { to: "/work", label: "Work", icon: Terminal, keybinding: { id: "shell.tab.work", fallback: "Mod+1" } }, + { to: "/lanes", label: "Lanes", icon: GitBranch, keybinding: { id: "shell.tab.lanes", fallback: "Mod+2" } }, + { to: "/files", label: "Files", icon: FileCode, keybinding: { id: "shell.tab.files", fallback: "Mod+3" } }, + { to: "/prs", label: "PRs", icon: GitPullRequest, keybinding: { id: "shell.tab.prs", fallback: "Mod+4" } }, + { + to: "/automations", + label: "Automations", + icon: Clock, + keybinding: { id: "shell.tab.automations", fallback: "Mod+5" }, + }, +]; + +export const PROJECT_SIDEBAR_FOOTER_TABS: ProjectSidebarTab[] = [ + { to: "/cto", label: "CTO", icon: Robot }, + { to: "/history", label: "History", icon: HourglassSimple }, +]; + +export const PROJECT_SIDEBAR_TOGGLE_KEYBINDING = { id: "shell.sidebar.toggle", fallback: "Mod+B" }; + +/** The tab whose path owns `pathname`, e.g. `/prs/123` belongs to `/prs`. */ +export function projectSidebarTabForPath(pathname: string): string | null { + const all = [...PROJECT_SIDEBAR_TABS, ...PROJECT_SIDEBAR_FOOTER_TABS]; + const match = all.find((tab) => pathname === tab.to || pathname.startsWith(`${tab.to}/`)); + return match?.to ?? null; +} + +/** The hosted web client only surfaces the tabs in `WEB_CLIENT_TAB_PATHS`. */ +export function visibleProjectSidebarTabs(tabs: ProjectSidebarTab[]): ProjectSidebarTab[] { + if (!isWebClientMode()) return tabs; + return tabs.filter((tab) => WEB_CLIENT_TAB_PATHS.has(tab.to)); +} + +/** Where a tab opens. PRs reopens the last PR route of this project. */ +export function projectSidebarTabTarget(to: string, projectRoot: string | null): string { + if (to === "/prs") return readStoredPrsRoute(projectRoot) ?? to; + return to; +} + +/** + * A chord as a menu shows it: `⌘B` on macOS, `Ctrl+B` elsewhere. Only the first + * alternative of a binding is shown. + */ +export function formatKeybindingLabel(binding: string): string { + const first = binding.split(",")[0]?.trim() ?? ""; + const parts = first.split("+").map((part) => part.trim()).filter(Boolean); + if (parts.length === 0) return ""; + const labels = parts.map((part) => { + const token = part.toLowerCase(); + if (token === "mod") return isMac ? "⌘" : "Ctrl"; + if (token === "cmd" || token === "meta" || token === "command") return isMac ? "⌘" : "Win"; + if (token === "ctrl" || token === "control") return isMac ? "⌃" : "Ctrl"; + if (token === "alt" || token === "option") return isMac ? "⌥" : "Alt"; + if (token === "shift") return isMac ? "⇧" : "Shift"; + return part.length === 1 ? part.toUpperCase() : part; + }); + return isMac ? labels.join("") : labels.join("+"); +} + +export function projectSidebarShortcutLabel( + keybindings: KeybindingsSnapshot | null, + keybinding: { id: string; fallback: string } | undefined, +): string | null { + if (!keybinding) return null; + return formatKeybindingLabel(getEffectiveBinding(keybindings, keybinding.id, keybinding.fallback)) || null; +} diff --git a/apps/desktop/src/renderer/components/app/projectSidebar/settingsReturnRoute.ts b/apps/desktop/src/renderer/components/app/projectSidebar/settingsReturnRoute.ts new file mode 100644 index 0000000000..6c5940f877 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/projectSidebar/settingsReturnRoute.ts @@ -0,0 +1,50 @@ +/** + * Settings is a place you visit and leave. For each project this remembers + * where you were before Settings (so the cog and the back button can return + * you there) and the last Settings section you used (so the cog reopens it). + * + * The project sidebar records every route it sees. Memory only: a restart + * returns to Work, which is also where the app opens. + */ + +const DEFAULT_RETURN_ROUTE = "/work"; + +const returnRouteByProject = new Map(); +const settingsRouteByProject = new Map(); + +function routePathname(route: string): string { + return route.split(/[?#]/, 1)[0] ?? ""; +} + +export function isSettingsRoute(route: string): boolean { + const pathname = routePathname(route); + return pathname === "/settings" || pathname.startsWith("/settings/"); +} + +/** CTO and History cover the page underneath. They are not a place Settings returns to. */ +function isOverlayRoute(route: string): boolean { + const pathname = routePathname(route); + return pathname === "/cto" || pathname === "/history"; +} + +/** Records the route on screen for a project. */ +export function rememberProjectRoute(projectKey: string | null, route: string): void { + if (!projectKey || !route || isOverlayRoute(route)) return; + if (isSettingsRoute(route)) settingsRouteByProject.set(projectKey, route); + else returnRouteByProject.set(projectKey, route); +} + +/** Where to go when you leave Settings. */ +export function settingsReturnRoute(projectKey: string | null): string { + return (projectKey && returnRouteByProject.get(projectKey)) || DEFAULT_RETURN_ROUTE; +} + +/** The last Settings route used in this project, if any. */ +export function lastSettingsRoute(projectKey: string | null): string | null { + return (projectKey && settingsRouteByProject.get(projectKey)) || null; +} + +export function resetSettingsReturnRoutesForTest(): void { + returnRouteByProject.clear(); + settingsRouteByProject.clear(); +} diff --git a/apps/desktop/src/renderer/components/app/projectSidebar/useProjectSidebarShortcuts.test.tsx b/apps/desktop/src/renderer/components/app/projectSidebar/useProjectSidebarShortcuts.test.tsx new file mode 100644 index 0000000000..c0e8dd9e33 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/projectSidebar/useProjectSidebarShortcuts.test.tsx @@ -0,0 +1,89 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { getProjectSidebarPrefs, setProjectSidebarHidden } from "./projectSidebarPrefs"; +import { useProjectSidebarShortcuts } from "./useProjectSidebarShortcuts"; + +const isMac = navigator.platform.toLowerCase().includes("mac"); +const mod = isMac ? { metaKey: true } : { ctrlKey: true }; + +function Harness({ enabled = true, navigate }: { enabled?: boolean; navigate: (path: string) => void }) { + useProjectSidebarShortcuts({ enabled, projectRoot: "/repo", keybindings: null, navigate }); + return ( +
+ +
+