From 2bc93a5d605970e520a11b2ee64f20ea5132d367 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 4 Aug 2026 14:59:54 +1000 Subject: [PATCH 01/18] feat(repos): one shared repo card with path labels and full action row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repos grid, the home repos row, and the pinned repos in the projects sidebar each had their own card markup, so they drifted: the grid and home cards led with the badge short name, and only the sidebar card carried the repo actions. Collapse all three onto a single RepoCard: - The card title is now the full repo/subpath, rendered by the shared RepoLabel (muted prefix, badge-hue emphasis on the distinguishing segment) instead of the short name. RepoLabel gains a `wrap` prop so a long path flows over as many lines as it needs rather than truncating. - Under the title sits the action row previously exclusive to the sidebar: new project, run (cloned) or clone (not cloned), pin/unpin, and the more menu with "Open in…" and "Copy Path". Unpin moves out of the more menu into a one-click pin toggle on every surface, replacing the grid's floating corner toggle. - The sidebar's pinned repos are the same card in `reorderable` mode, so they keep drag-to-reorder while picking up the grid's tint, border and layout. The more menu now resolves the clone path and opener apps when it first opens instead of on mount, so a grid of N repos no longer fires N get_repo_clone_path calls up front. SidebarPinnedRepo is deleted, and the grid's duplicated card CSS along with it; ReposListView and ProjectsList only own their own layout (grid cell stretch, 200px scroll-row width). Verified with `just typecheck`, `just fmt-check`, `just test-frontend`, and a `VITE_REPOS_UI_ENABLED=true` production build. The repos UI stays behind that flag, so this is dark by default. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Matt Toohey --- .../lib/features/projects/ProjectsList.svelte | 22 +- .../features/projects/ProjectsSidebar.svelte | 9 +- .../src/lib/features/projects/RepoCard.svelte | 394 ++++++++++++++---- .../features/projects/ReposListView.svelte | 182 +------- .../projects/SidebarPinnedRepo.svelte | 367 ---------------- apps/staged/src/lib/shared/RepoLabel.svelte | 13 +- 6 files changed, 340 insertions(+), 647 deletions(-) delete mode 100644 apps/staged/src/lib/features/projects/SidebarPinnedRepo.svelte diff --git a/apps/staged/src/lib/features/projects/ProjectsList.svelte b/apps/staged/src/lib/features/projects/ProjectsList.svelte index ca052d411..62a0526da 100644 --- a/apps/staged/src/lib/features/projects/ProjectsList.svelte +++ b/apps/staged/src/lib/features/projects/ProjectsList.svelte @@ -15,8 +15,7 @@ import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal'; import Sprout from '@lucide/svelte/icons/sprout'; import Trash2 from '@lucide/svelte/icons/trash-2'; - import type { Project, WorkspaceStatus, RepoHomeItem } from '../../types'; - import * as commands from '../../api/commands'; + import type { Project, WorkspaceStatus } from '../../types'; import RepoCard from './RepoCard.svelte'; import { projectDisplayName, @@ -36,7 +35,6 @@ import Spinner from '../../shared/Spinner.svelte'; import SineWave from '../../shared/SineWave.svelte'; import RepoLabel from '../../shared/RepoLabel.svelte'; - import { toast } from 'svelte-sonner'; import { Button } from '$lib/components/ui/button'; import { @@ -294,17 +292,6 @@ .catch(console.error); }); - async function handleCloneRepo(repo: RepoHomeItem) { - try { - await commands.cloneRepoLocally(repo.githubRepo); - await projectsDataStore.refreshHomeRepos(); - } catch (e) { - console.error('[ProjectsList] Failed to clone repo:', e); - const message = e instanceof Error ? e.message : String(e); - toast.error('Failed to clone repo', { description: message }); - } - } - function handleProjectCreated(project: Project) { projectsDataStore.projectCreated(project); showNewProjectModal = false; @@ -465,7 +452,7 @@
{#each homeRepos as repo (repo.githubRepo + ':' + repo.subpath)} - handleCloneRepo(repo)} /> + projectsDataStore.refreshHomeRepos()} /> {/each}
@@ -850,6 +837,11 @@ scrollbar-color: var(--border-muted) transparent; } + .repos-scroll-row > :global(.repo-card) { + width: 200px; + flex-shrink: 0; + } + .repos-scroll-row::-webkit-scrollbar { height: 4px; } diff --git a/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte b/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte index 245f0af7d..800e96c90 100644 --- a/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte +++ b/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte @@ -40,7 +40,7 @@ SIDEBAR_MIN_WIDTH, } from './projectsSidebarState.svelte'; import { viewport, watchViewport } from '../../shared/viewport.svelte'; - import SidebarPinnedRepo from './SidebarPinnedRepo.svelte'; + import RepoCard from './RepoCard.svelte'; import * as commands from '../../api/commands'; import { projectActions } from './projectActions.svelte'; import * as ContextMenu from '$lib/components/ui/context-menu'; @@ -435,12 +435,14 @@
{#each pinnedRepos as repo, index (repo.githubRepo + '\t' + repo.subpath)} - projectsDataStore.refreshHomeRepos()} /> {/each}
@@ -898,7 +900,8 @@ .pinned-repos-list { display: flex; flex-direction: column; - gap: 3px; + gap: 6px; + padding: 2px 0; } .section-divider { diff --git a/apps/staged/src/lib/features/projects/RepoCard.svelte b/apps/staged/src/lib/features/projects/RepoCard.svelte index a23b6d1f4..1fd7457d0 100644 --- a/apps/staged/src/lib/features/projects/RepoCard.svelte +++ b/apps/staged/src/lib/features/projects/RepoCard.svelte @@ -1,14 +1,25 @@
- + + + - {repo.shortName} +
+ - {subtitle} - - {#if !repo.hasLocalClone} - - {/if} + {:else} + + + + {/if} + + + + {#if repo.hasLocalClone} + { + if (open) void loadCloneDetails(); + }} + > + + + + + {#if clonePath} + {@const path = clonePath} + {#if openerApps.length > 0} + + + Open in… + + + {#each openerApps as app (app.id)} + handleOpenInApp(path, app)}> + {#if app.icon} + + {/if} + {app.name} + + {/each} + + + {/if} + copyPathToClipboard(path)}> + Copy Path + + {:else} + Loading… + {/if} + + + {/if} +
diff --git a/apps/staged/src/lib/features/projects/ReposListView.svelte b/apps/staged/src/lib/features/projects/ReposListView.svelte index 4fb7246db..3df437db3 100644 --- a/apps/staged/src/lib/features/projects/ReposListView.svelte +++ b/apps/staged/src/lib/features/projects/ReposListView.svelte @@ -2,38 +2,23 @@ ReposListView.svelte - Full grid view of all repos with search and pin management. Shows pinned repos first (by sort order), then unpinned (by project count). - Each card has a pin/unpin toggle. Includes a search input for filtering. + Each card is the shared RepoCard, so the grid carries the same repo path label + and action row as the pinned repos in the projects sidebar. --> @@ -135,63 +75,8 @@ {:else}
{#each filteredRepos as repo (repoKey(repo))} - {@const accent = badgeFg(repo.hue, darkMode.value)} - {@const bg = badgeBg(repo.hue, darkMode.value)} - {@const bgHover = badgeBgHover(repo.hue, darkMode.value)} - {@const border = badgeBorder(repo.hue, darkMode.value)} - {@const borderHover = badgeBorderHover(repo.hue, darkMode.value)} - {@const key = repoKey(repo)}
-
- - - {repo.shortName} - {subtitle(repo)} - - {#if !repo.hasLocalClone} - - {/if} -
+ {#if repo.pinned}
Pinned
{/if} @@ -270,7 +155,7 @@ gap: 6px; } - .repo-card-wrapper .repo-card { + .repo-card-wrapper > :global(.repo-card) { flex: 1; } @@ -280,49 +165,6 @@ padding: 0 4px; } - .repo-card { - position: relative; - display: flex; - flex-direction: column; - gap: 4px; - text-align: left; - min-height: 120px; - padding: 14px; - border: 1px solid var(--card-border); - border-radius: 10px; - background: var(--card-bg); - color: inherit; - transition: all 0.15s ease; - box-sizing: border-box; - } - - .card-title { - font-size: var(--size-md); - font-weight: 700; - color: var(--accent); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding-right: 32px; - } - - .card-subtitle { - font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; - font-size: 11px; - font-weight: 500; - color: var(--text-muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .card-footer { - margin-top: auto; - display: flex; - align-items: center; - min-height: 20px; - } - @media (max-width: 900px) { .repos-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -338,9 +180,5 @@ grid-template-columns: minmax(0, 1fr); gap: 10px; } - - .repo-card { - min-height: 104px; - } } diff --git a/apps/staged/src/lib/features/projects/SidebarPinnedRepo.svelte b/apps/staged/src/lib/features/projects/SidebarPinnedRepo.svelte deleted file mode 100644 index ea19e187f..000000000 --- a/apps/staged/src/lib/features/projects/SidebarPinnedRepo.svelte +++ /dev/null @@ -1,367 +0,0 @@ - - - -
-
- -
-
- {repo.shortName} - {#if subpathLabel} - {subpathLabel} - {/if} -
- -
- - - {#if repo.hasLocalClone} - - {:else} - - - - {/if} - - - - - - - {#if repo.hasLocalClone && clonePath} - {@const path = clonePath} - {#if openerApps.length > 0} - - - Open in… - - - {#each openerApps as app (app.id)} - handleOpenInApp(path, app)}> - {#if app.icon} - - {/if} - {app.name} - - {/each} - - - {/if} - copyPathToClipboard(path)}> - Copy Path - - - {/if} - - Unpin Repo - - - -
-
-
- - diff --git a/apps/staged/src/lib/shared/RepoLabel.svelte b/apps/staged/src/lib/shared/RepoLabel.svelte index 1bb11d7a6..445763146 100644 --- a/apps/staged/src/lib/shared/RepoLabel.svelte +++ b/apps/staged/src/lib/shared/RepoLabel.svelte @@ -13,9 +13,11 @@ interface Props { githubRepo: string; subpath?: string | null; + /** Wrap across as many lines as the path needs instead of truncating it. */ + wrap?: boolean; } - let { githubRepo, subpath = null }: Props = $props(); + let { githubRepo, subpath = null, wrap = false }: Props = $props(); let prefix = $derived.by(() => { if (subpath) { @@ -36,7 +38,7 @@ let fullLabel = $derived(subpath ? `${githubRepo}/${subpath}` : githubRepo); -{#if prefix}{prefix}{/if}{emphasis} Date: Tue, 4 Aug 2026 16:13:17 +1000 Subject: [PATCH 02/18] feat(repos): pinned/all grid sections and a full-action more menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up feedback on the shared RepoCard surfaces: - The repos grid splits into "Pinned repos" and "All repos" (the unpinned rest) sections instead of one mixed grid, dropping the "Pinned" caption that sat under pinned cards along with its wrapper markup. - The more menu now carries the full action set — New Project, Run or Clone Repo, and Pin/Unpin Repo — ahead of the local-clone Open in… / Copy Path items, and renders on every card instead of only cloned ones (a not-yet-cloned repo previously had no menu at all, which would have left a not-yet-cloned sidebar repo with no way to unpin). - The sidebar's pinned cards hide the inline pin toggle via the new hidePinButton prop, so unpin there is only reachable through the more menu rather than as a one-click button next to the drag handle. Verified with `just typecheck`, `just fmt-check`, `just test-frontend`, and a `VITE_REPOS_UI_ENABLED=true` production build. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../features/projects/ProjectsSidebar.svelte | 1 + .../src/lib/features/projects/RepoCard.svelte | 127 +++++++++++------- .../features/projects/ReposListView.svelte | 61 +++++---- 3 files changed, 113 insertions(+), 76 deletions(-) diff --git a/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte b/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte index 800e96c90..bbab9637d 100644 --- a/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte +++ b/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte @@ -437,6 +437,7 @@ {#each pinnedRepos as repo, index (repo.githubRepo + '\t' + repo.subpath)} !v && onClose()}> @@ -23,6 +26,6 @@ New Project - + diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index a5897a8a7..8763fd92e 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -42,6 +42,7 @@ canDeleteProjectWithoutConfirmation, computeSafeToDeleteSignature, } from './projectDeleteSafety'; + import { repoSeedFromNewProjectEvent } from './newProjectEvent'; interface Props { selectedProjectId?: string | null; @@ -100,6 +101,7 @@ // Modal state let showNewProjectModal = $state(false); + let newProjectInitialRepo = $state(null); let showAddRepoModal = $state(false); // Project-detail top-bar title handoff. @@ -135,7 +137,7 @@ checkStoreAndLoad(); void projectRunActionsStore.startListening(); - const onNewProject = () => handleNewProject(); + const onNewProject = (event: Event) => handleNewProject(repoSeedFromNewProjectEvent(event)); window.addEventListener('staged:new-project', onNewProject); const onDeleteCurrentProject = (event: Event) => handleDeleteCurrentProjectShortcut(event); window.addEventListener('staged:delete-current-project', onDeleteCurrentProject); @@ -508,7 +510,8 @@ // ── Project actions ── - function handleNewProject() { + function handleNewProject(initialRepo: RepoPickerSelection | null = null) { + newProjectInitialRepo = initialRepo; showNewProjectModal = true; } @@ -898,6 +901,7 @@ (showNewProjectModal = false)} /> diff --git a/apps/staged/src/lib/features/projects/ProjectsList.svelte b/apps/staged/src/lib/features/projects/ProjectsList.svelte index 62a0526da..fa9e38968 100644 --- a/apps/staged/src/lib/features/projects/ProjectsList.svelte +++ b/apps/staged/src/lib/features/projects/ProjectsList.svelte @@ -45,6 +45,8 @@ import { darkMode } from '../../stores/isDark.svelte'; import { repoBadgeStore } from '../../stores/repoBadges.svelte'; import { badgeBg, badgeFg, badgeBgHover } from '../../shared/badgeColors'; + import { repoSeedFromNewProjectEvent } from './newProjectEvent'; + import type { RepoSelection } from '../../shared/githubUrl'; import { viewport } from '../../shared/viewport.svelte'; import { reposUiEnabled } from '../../featureFlags'; import TopBarPortal from '../layout/TopBarPortal.svelte'; @@ -67,6 +69,7 @@ let error = $derived(projectsDataStore.error); let showNewProjectModal = $state(false); + let newProjectInitialRepo = $state(null); let isCommandKeyHeld = $state(false); let mainPanelEl = $state(null); let activeFilters = $state>(new Set()); @@ -264,7 +267,8 @@ } void projectRunActionsStore.startListening(); - const onNewProject = () => { + const onNewProject = (event: Event) => { + newProjectInitialRepo = repoSeedFromNewProjectEvent(event); showNewProjectModal = true; }; window.addEventListener('staged:new-project', onNewProject); @@ -407,7 +411,10 @@ size="icon-xs" class="max-md:size-10 [&_svg]:size-3.5" aria-label="New project" - onclick={() => (showNewProjectModal = true)} + onclick={() => { + newProjectInitialRepo = null; + showNewProjectModal = true; + }} > @@ -664,6 +671,7 @@ 0} + initialRepo={newProjectInitialRepo} onCreated={handleProjectCreated} onClose={() => (showNewProjectModal = false)} /> diff --git a/apps/staged/src/lib/features/projects/RepoCard.svelte b/apps/staged/src/lib/features/projects/RepoCard.svelte index e3bcf182c..8d38bd0c9 100644 --- a/apps/staged/src/lib/features/projects/RepoCard.svelte +++ b/apps/staged/src/lib/features/projects/RepoCard.svelte @@ -41,6 +41,7 @@ type OpenerApp, } from '../branches/branch'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; + import type { NewProjectEventDetail } from './newProjectEvent'; import * as commands from '../../api/commands'; import { toast } from 'svelte-sonner'; import { Button } from '$lib/components/ui/button'; @@ -104,11 +105,8 @@ } function openNewProjectForRepo() { - window.dispatchEvent( - new CustomEvent('staged:new-project', { - detail: { githubRepo: repo.githubRepo, subpath: repo.subpath }, - }) - ); + const detail: NewProjectEventDetail = { githubRepo: repo.githubRepo, subpath: repo.subpath }; + window.dispatchEvent(new CustomEvent('staged:new-project', { detail })); } function handleRun() { diff --git a/apps/staged/src/lib/features/projects/ReposListView.svelte b/apps/staged/src/lib/features/projects/ReposListView.svelte index 705d8d5ed..665cb3922 100644 --- a/apps/staged/src/lib/features/projects/ReposListView.svelte +++ b/apps/staged/src/lib/features/projects/ReposListView.svelte @@ -9,11 +9,15 @@ + +{#snippet renderSubItems(subItems: MenuItem[])} + {#each subItems as item, i (i)} + {#if item.type === 'separator'} + + {:else if item.type === 'action'} + + {#if item.icon} + {@const Icon = item.icon} + + {/if} + {item.label} + + {/if} + {/each} +{/snippet} + +{#if items.length > 0} + + + Actions + + + {#each items as item, i (i)} + {#if item.type === 'separator'} + + {:else if item.type === 'submenu'} + + + {#if item.icon} + {@const Icon = item.icon} + + {/if} + {item.label} + + + {@render renderSubItems(item.children)} + + + {:else} + + {#if item.icon} + {@const Icon = item.icon} + + {/if} + {item.label} + + {/if} + {/each} + + +{/if} diff --git a/apps/staged/src/lib/features/actions/PrimaryRunActionButton.svelte b/apps/staged/src/lib/features/actions/PrimaryRunActionButton.svelte new file mode 100644 index 000000000..4df813b59 --- /dev/null +++ b/apps/staged/src/lib/features/actions/PrimaryRunActionButton.svelte @@ -0,0 +1,246 @@ + + + +{#if show && primaryRunAction} + {@const execution = runner.primaryActionExecution} + {@const isRunning = execution?.status === 'running'} + {@const isStopping = execution && runner.stoppingExecutions.has(execution.executionId)} + {@const showStopIcon = altKey.held && isRunning && !isStopping} + {@const phase = execution ? runner.runPhases.get(execution.executionId) : undefined} + {@const hasEndpoint = phase?.type === 'running' && !!phase.endpoint && canResolveEndpoint} + {@const copyUrl = + hasEndpoint && phase?.type === 'running' && phase.endpoint + ? getEndpointCopyUrl(phase.endpoint) + : ''} +
+ {#if isRunning && hasEndpoint && phase?.type === 'running' && phase.endpoint} + +
+ + +
+ {:else} + + + {/if} +
+{/if} + + diff --git a/apps/staged/src/lib/features/actions/RunningActionPills.svelte b/apps/staged/src/lib/features/actions/RunningActionPills.svelte new file mode 100644 index 000000000..5e0660d82 --- /dev/null +++ b/apps/staged/src/lib/features/actions/RunningActionPills.svelte @@ -0,0 +1,135 @@ + + + +{#each runner.secondaryRunningActions as execution (execution.executionId)} + {@const isRunning = execution.status === 'running'} + {@const isStopping = runner.stoppingExecutions.has(execution.executionId)} + {@const showStopIcon = altKey.held && isRunning && !isStopping} + {@const phase = runner.runPhases.get(execution.executionId)} +
+ +
+{/each} + + diff --git a/apps/staged/src/lib/features/actions/actionGroups.ts b/apps/staged/src/lib/features/actions/actionGroups.ts new file mode 100644 index 000000000..e1070f8a4 --- /dev/null +++ b/apps/staged/src/lib/features/actions/actionGroups.ts @@ -0,0 +1,55 @@ +/** + * Pure helpers for grouping a scope's configured actions and splitting its + * running executions into the primary run action vs everything else. + * Shared by the action-runner state machine and the Actions submenu builder. + */ + +import type { ProjectAction } from '../../api/commands'; + +export function groupActionsByType(actions: ProjectAction[]): Record { + const groups: Record = { + prerun: [], + run: [], + build: [], + format: [], + check: [], + test: [], + cleanUp: [], + }; + + for (const action of actions) { + if (groups[action.actionType]) { + groups[action.actionType].push(action); + } + } + + return groups; +} + +export function getPrimaryRunAction( + groupedActions: Record +): ProjectAction | null { + return groupedActions.run?.[0] ?? null; +} + +export function getRemainingRunActions( + groupedActions: Record +): ProjectAction[] { + return groupedActions.run?.slice(1) ?? []; +} + +export function getPrimaryActionExecution( + runningActions: T[], + primaryRunActionId: string | null +): T | null { + if (!primaryRunActionId) return null; + return runningActions.find((a) => a.actionId === primaryRunActionId) ?? null; +} + +export function getSecondaryRunningActions( + runningActions: T[], + primaryRunActionId: string | null +): T[] { + if (!primaryRunActionId) return runningActions; + return runningActions.filter((a) => a.actionId !== primaryRunActionId); +} diff --git a/apps/staged/src/lib/features/actions/actionMenu.ts b/apps/staged/src/lib/features/actions/actionMenu.ts new file mode 100644 index 000000000..20c30cd7f --- /dev/null +++ b/apps/staged/src/lib/features/actions/actionMenu.ts @@ -0,0 +1,109 @@ +/** + * Builder for the "Actions" submenu shown in a card's more menu: one group + * per action type (separated), with the primary run action excluded (it has + * its own button) and Format & Check collapsed into a nested submenu when + * they'd crowd the list. The MenuItem shape is also used by other submenu + * builders (e.g. the branch card's Open In menu). + */ + +import Play from '@lucide/svelte/icons/play'; +import Hammer from '@lucide/svelte/icons/hammer'; +import FlaskConical from '@lucide/svelte/icons/flask-conical'; +import CheckCircle from '@lucide/svelte/icons/check-circle'; +import Wrench from '@lucide/svelte/icons/wrench'; +import Zap from '@lucide/svelte/icons/zap'; +import Wand2 from '@lucide/svelte/icons/wand-2'; +import type { ProjectAction } from '../../api/commands'; +import type { ActionType } from './actions'; + +export type MenuIconComponent = typeof Play; +export type ActionMenuItem = { + type: 'action'; + label: string; + icon?: MenuIconComponent; + iconSrc?: string; + disabled?: boolean; + danger?: boolean; + onSelect: () => void | Promise; +}; +export type SeparatorMenuItem = { type: 'separator' }; +export type SubmenuMenuItem = { + type: 'submenu'; + label: string; + icon?: MenuIconComponent; + disabled?: boolean; + children: MenuItem[]; +}; +export type MenuItem = ActionMenuItem | SeparatorMenuItem | SubmenuMenuItem; + +const actionMenuTypes = ['run', 'build', 'format', 'check', 'test', 'cleanUp', 'prerun'] as const; + +export function getActionIcon(actionType: string): MenuIconComponent { + switch (actionType) { + case 'prerun': + return Zap; + case 'run': + return Play; + case 'build': + return Hammer; + case 'format': + return Wand2; + case 'check': + return CheckCircle; + case 'test': + return FlaskConical; + case 'cleanUp': + return Wrench; + default: + return Wrench; + } +} + +export function buildActionMenuItems( + groupedActions: Record, + remainingRunActions: ProjectAction[], + onRun: (action: ProjectAction) => void | Promise +): MenuItem[] { + const toActionItem = (type: ActionType, action: ProjectAction): MenuItem => ({ + type: 'action', + label: action.name, + icon: getActionIcon(type), + onSelect: () => onRun(action), + }); + + const formatItems = groupedActions.format.map((a) => toActionItem('format', a)); + const checkItems = groupedActions.check.map((a) => toActionItem('check', a)); + const combineFormatCheck = formatItems.length + checkItems.length > 2; + + const groups: MenuItem[][] = []; + for (const type of actionMenuTypes) { + if (combineFormatCheck && type === 'check') continue; + if (combineFormatCheck && type === 'format') { + const children: MenuItem[] = [ + ...formatItems, + ...(formatItems.length && checkItems.length ? [{ type: 'separator' as const }] : []), + ...checkItems, + ]; + groups.push([ + { + type: 'submenu', + label: 'Format & Check', + icon: Wand2, + children, + }, + ]); + continue; + } + + const typeActions = type === 'run' ? remainingRunActions : groupedActions[type]; + if (!typeActions || typeActions.length === 0) continue; + groups.push(typeActions.map((action) => toActionItem(type, action))); + } + + const items: MenuItem[] = []; + for (const group of groups) { + if (items.length > 0) items.push({ type: 'separator' }); + items.push(...group); + } + return items; +} diff --git a/apps/staged/src/lib/features/actions/actionRunner.svelte.ts b/apps/staged/src/lib/features/actions/actionRunner.svelte.ts new file mode 100644 index 000000000..c55ac5098 --- /dev/null +++ b/apps/staged/src/lib/features/actions/actionRunner.svelte.ts @@ -0,0 +1,350 @@ +/** + * ActionRunner — the shared action-runner state machine behind a card's + * action surfaces (running pills, primary run button, Actions submenu, + * output modal). + * + * Owns the configured action list, the live running-execution set (hydrated + * via get_running_branch_actions, updated by action_status and + * action:run-phase-changed events), stop/fade-out bookkeeping, and the output + * modal state. The execution pipeline treats its routing id as an opaque + * string, so the runner is parameterized by a scope id — a branch id, or the + * synthetic repo scope id from repoActionScopeId() — plus loadActions/run + * callbacks, letting branch cards and repo cards share one implementation. + */ + +import { toast } from 'svelte-sonner'; +import type { ProjectAction } from '../../api/commands'; +import { + clearActionExecution, + getRunningBranchActions, + getRunPhase, + stopBranchAction, + type ActionStatusEvent, + type ActionType, + type RunPhase, +} from './actions'; +import { onBranchActionStatus, onBranchRunPhaseChanged } from '../../services/branchEventService'; +import { + getPrimaryActionExecution, + getPrimaryRunAction, + getRemainingRunActions, + getSecondaryRunningActions, + groupActionsByType, +} from './actionGroups'; + +export type RunningAction = { + executionId: string; + actionId: string; + actionName: string; + actionType: ActionType; + status: 'running' | 'completed' | 'failed' | 'stopped'; + exitCode?: number | null; + startedAt?: number; + completedAt?: number | null; + fading?: boolean; +}; + +export interface ActionOutputModalState { + executionId: string; + actionId: string; + actionName: string; + isStopping: boolean; +} + +export interface ActionRunnerOptions { + /** + * Opaque id the scope's executions are routed under: a branch id, or the + * synthetic repo scope id from repoActionScopeId(). Read lazily so + * subscribe() re-tracks it when called inside an $effect. + */ + getScopeId: () => string; + /** Load the scope's configured actions (listProjectActions / listRepoActions). */ + loadActions: () => Promise; + /** Start an action and return its execution id (runBranchAction / runRepoAction). */ + run: (actionId: string) => Promise; +} + +function notifyError(title: string, e: unknown): void { + toast.error(title, { + description: e instanceof Error ? e.message : String(e), + duration: Infinity, + }); +} + +export class ActionRunner { + private getScopeId: () => string = undefined!; + private load: () => Promise = undefined!; + private run: (actionId: string) => Promise = undefined!; + + actions = $state([]); + runningActions = $state([]); + stoppingExecutions = $state>(new Set()); + + // Run phase tracking for run actions (building, running, endpoint detection) + runPhases = $state(new Map()); + + outputModal = $state(null); + + groupedActions = $derived(groupActionsByType(this.actions)); + primaryRunAction = $derived(getPrimaryRunAction(this.groupedActions)); + remainingRunActions = $derived(getRemainingRunActions(this.groupedActions)); + primaryActionExecution = $derived( + getPrimaryActionExecution(this.runningActions, this.primaryRunAction?.id ?? null) + ); + secondaryRunningActions = $derived( + getSecondaryRunningActions(this.runningActions, this.primaryRunAction?.id ?? null) + ); + + constructor(opts: ActionRunnerOptions) { + this.getScopeId = opts.getScopeId; + this.load = opts.loadActions; + this.run = opts.run; + } + + /** + * Subscribe to status and run-phase events for the current scope id. + * Call inside an $effect and return the unlisten so a scope-id change + * re-subscribes. + */ + subscribe(): () => void { + const scopeId = this.getScopeId(); + + const unlistenActionStatus = onBranchActionStatus(scopeId, (payload) => + this.applyStatusEvent(payload) + ); + + const unlistenRunPhaseChanged = onBranchRunPhaseChanged(scopeId, (event) => { + this.runPhases.set(event.executionId, event.phase); + this.runPhases = new Map(this.runPhases); + }); + + return () => { + unlistenActionStatus(); + unlistenRunPhaseChanged(); + }; + } + + private applyStatusEvent(payload: ActionStatusEvent): void { + const existingIndex = this.runningActions.findIndex( + (a) => a.executionId === payload.executionId + ); + + if (payload.status === 'running') { + if (existingIndex === -1) { + this.runningActions.push({ + executionId: payload.executionId, + actionId: payload.actionId, + actionName: payload.actionName, + actionType: payload.actionType, + status: 'running', + startedAt: payload.startedAt ?? Date.now(), + }); + } + } else { + // Action completed/failed/stopped - update status + if (existingIndex !== -1) { + this.runningActions[existingIndex].status = payload.status; + this.runningActions[existingIndex].exitCode = payload.exitCode; + this.runningActions[existingIndex].completedAt = payload.completedAt; + + // Clean up stopping state and run phase when action reaches terminal state + if ( + payload.status === 'stopped' || + payload.status === 'completed' || + payload.status === 'failed' + ) { + const updated = new Set(this.stoppingExecutions); + updated.delete(payload.executionId); + this.stoppingExecutions = updated; + + this.runPhases.delete(payload.executionId); + this.runPhases = new Map(this.runPhases); + } + + // Auto-remove terminal states after a delay + const action = this.runningActions[existingIndex]; + const isPrimaryAction = + this.primaryRunAction && action.actionId === this.primaryRunAction.id; + + // Determine delay based on status: completed shows briefly, stopped/failed show longer + let displayTime: number; + if (payload.status === 'completed') { + displayTime = isPrimaryAction ? 1000 : 2000; + } else { + // stopped/failed: show status briefly then clean up so rerun works cleanly + displayTime = isPrimaryAction ? 2000 : 3000; + } + + setTimeout(() => { + const foundAction = this.runningActions.find( + (a) => a.executionId === payload.executionId + ); + if (foundAction && !isPrimaryAction) { + // Secondary actions fade out + foundAction.fading = true; + } + // Remove after animation completes (or immediately for primary) + setTimeout( + () => { + this.runningActions = this.runningActions.filter( + (a) => a.executionId !== payload.executionId + ); + }, + isPrimaryAction ? 0 : 300 + ); // Match CSS transition duration for secondary + }, displayTime); + } + } + } + + async loadActions(): Promise { + try { + this.actions = await this.load(); + } catch (e) { + console.error('Failed to load actions:', e); + this.actions = []; + } + } + + async loadRunningActions(): Promise { + try { + const running = await getRunningBranchActions(this.getScopeId()); + + for (const info of running) { + const existingIndex = this.runningActions.findIndex( + (a) => a.executionId === info.executionId + ); + if (existingIndex === -1) { + this.runningActions.push({ + executionId: info.executionId, + actionId: info.actionId, + actionName: info.actionName, + actionType: info.actionType, + status: 'running', + startedAt: info.startedAt, + }); + } + + try { + const phase = await getRunPhase(info.executionId); + if (phase) { + this.runPhases.set(info.executionId, phase); + } else if (info.actionType === 'run') { + this.runPhases.set(info.executionId, { type: 'running', endpoint: null }); + } + } catch { + // Phase not available for this execution + } + } + this.runPhases = new Map(this.runPhases); + } catch (e) { + console.error('Failed to load running actions:', e); + } + } + + /** Drop terminal executions of an action and clear their output buffers. */ + private clearStaleExecutions(actionId: string): void { + const staleExecutions = this.runningActions.filter( + (a) => a.actionId === actionId && a.status !== 'running' + ); + for (const stale of staleExecutions) { + clearActionExecution(stale.executionId).catch(() => {}); + } + this.runningActions = this.runningActions.filter( + (a) => !(a.actionId === actionId && a.status !== 'running') + ); + } + + /** Run an action, or open the output modal if it's already running. */ + async runAction(action: ProjectAction): Promise { + this.clearStaleExecutions(action.id); + + const existingExecution = this.runningActions.find( + (a) => a.actionId === action.id && a.status === 'running' + ); + + if (existingExecution) { + this.outputModal = { + executionId: existingExecution.executionId, + actionId: action.id, + actionName: action.name, + isStopping: this.stoppingExecutions.has(existingExecution.executionId), + }; + return; + } + + try { + await this.run(action.id); + } catch (e) { + console.error('Failed to run action:', e); + notifyError(`Failed to run action "${action.name}"`, e); + } + } + + async stopAction(executionId: string, actionName: string): Promise { + if (this.stoppingExecutions.has(executionId)) { + return; + } + + this.stoppingExecutions = new Set(this.stoppingExecutions).add(executionId); + + try { + await stopBranchAction(executionId); + } catch (e) { + const updated = new Set(this.stoppingExecutions); + updated.delete(executionId); + this.stoppingExecutions = updated; + console.error(`Failed to stop action ${actionName}:`, e); + notifyError(`Failed to stop action "${actionName}"`, e); + } + } + + showOutput(execution: RunningAction): void { + this.outputModal = { + executionId: execution.executionId, + actionId: execution.actionId, + actionName: execution.actionName, + isStopping: this.stoppingExecutions.has(execution.executionId), + }; + } + + /** Re-run the output modal's action, keeping the modal open on the new execution. */ + async runAgain(): Promise { + const action = this.actions.find((a) => a.id === this.outputModal?.actionId); + if (!action) return; + + this.clearStaleExecutions(action.id); + + // If already running, just switch the modal to that execution + const existingExecution = this.runningActions.find( + (a) => a.actionId === action.id && a.status === 'running' + ); + if (existingExecution) { + this.outputModal = { + executionId: existingExecution.executionId, + actionId: action.id, + actionName: action.name, + isStopping: this.stoppingExecutions.has(existingExecution.executionId), + }; + return; + } + + try { + const newExecutionId = await this.run(action.id); + // Keep the modal open and switch to the new execution + this.outputModal = { + executionId: newExecutionId, + actionId: action.id, + actionName: action.name, + isStopping: false, + }; + } catch (e) { + console.error('Failed to run action:', e); + notifyError(`Failed to run action "${action.name}"`, e); + } + } + + closeOutputModal(): void { + this.outputModal = null; + } +} diff --git a/apps/staged/src/lib/features/actions/altKey.svelte.ts b/apps/staged/src/lib/features/actions/altKey.svelte.ts new file mode 100644 index 000000000..92f91f696 --- /dev/null +++ b/apps/staged/src/lib/features/actions/altKey.svelte.ts @@ -0,0 +1,37 @@ +/** + * Shared Alt-key tracking for the quick stop-action affordance: while Alt is + * held, running-action buttons swap to a stop icon. One pair of window + * listeners is shared by every mounted tracker via reference counting. + */ + +let held = $state(false); +let trackers = 0; + +function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Alt') held = true; +} + +function handleKeyUp(e: KeyboardEvent) { + if (e.key === 'Alt') held = false; +} + +export const altKey = { + get held() { + return held; + }, +}; + +/** Track the Alt key while mounted; returns a cleanup (usable as an onMount return). */ +export function trackAltKey(): () => void { + if (trackers++ === 0) { + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + } + return () => { + if (--trackers === 0) { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + held = false; + } + }; +} diff --git a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte index 5a2d5518f..7ded99ea8 100644 --- a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte @@ -3,81 +3,35 @@ Displays running action buttons, primary run action button/pill, and the "more" dropdown menu with Actions and Open In submenus. + + The action-running machinery (state machine, pills, primary button, Actions + submenu, output modal plumbing) is the shared ActionRunner from the actions + feature, scoped here to the branch id. --> - + {#if isLocal || (isRemote && remoteWorkspaceStatus === 'running')} - {#each secondaryRunningActions as execution (execution.executionId)} - {@const isRunning = execution.status === 'running'} - {@const isStopping = stoppingExecutions.has(execution.executionId)} - {@const showStopIcon = altHeld && isRunning && !isStopping} - {@const phase = runPhases.get(execution.executionId)} -
- -
- {/each} - - {#if !isSettingUp && primaryRunAction} - {@const execution = primaryActionExecution} - {@const isRunning = execution?.status === 'running'} - {@const isStopping = execution && stoppingExecutions.has(execution.executionId)} - {@const showStopIcon = altHeld && isRunning && !isStopping} - {@const phase = execution ? runPhases.get(execution.executionId) : undefined} - {@const hasEndpoint = phase?.type === 'running' && !!phase.endpoint && canResolveEndpoint} - {@const copyUrl = - hasEndpoint && phase?.type === 'running' && phase.endpoint - ? getEndpointCopyUrl(phase.endpoint) - : ''} -
- {#if isRunning && hasEndpoint && phase?.type === 'running' && phase.endpoint} - -
- - -
- {:else} - - - {/if} -
- {/if} + + {/if} {#snippet renderSubItems(items: MenuItem[])} {#each items as item, i (i)} @@ -870,41 +251,7 @@ Copy Workspace Name {/if} - {#if hasActionsForSubmenu} - - - Actions - - - {#each actionMenuItems as item, i (i)} - {#if item.type === 'separator'} - - {:else if item.type === 'submenu'} - - - {#if item.icon} - {@const Icon = item.icon} - - {/if} - {item.label} - - - {@render renderSubItems(item.children)} - - - {:else} - - {#if item.icon} - {@const Icon = item.icon} - - {/if} - {item.label} - - {/if} - {/each} - - - {/if} + {#if isLocal && branch.worktreePath && openerApps.length > 0} @@ -944,97 +291,12 @@ (actionOutputModal = null)} - onRunAgain={async () => { - const action = actions.find((a) => a.id === actionOutputModal?.actionId); - if (!action) return; - - // Clean up stale executions of this action - const staleExecutions = runningActions.filter( - (a) => a.actionId === action.id && a.status !== 'running' - ); - for (const stale of staleExecutions) { - clearActionExecution(stale.executionId).catch(() => {}); - } - runningActions = runningActions.filter( - (a) => !(a.actionId === action.id && a.status !== 'running') - ); - - // If already running, just switch the modal to that execution - const existingExecution = runningActions.find( - (a) => a.actionId === action.id && a.status === 'running' - ); - if (existingExecution) { - actionOutputModal = { - executionId: existingExecution.executionId, - actionId: action.id, - actionName: action.name, - isStopping: stoppingExecutions.has(existingExecution.executionId), - }; - return; - } - - try { - const provider = getPreferredAgent(agentState.providers) ?? undefined; - const newExecutionId = await runBranchAction(branch.id, action.id, provider); - // Keep the modal open and switch to the new execution - actionOutputModal = { - executionId: newExecutionId, - actionId: action.id, - actionName: action.name, - isStopping: false, - }; - } catch (e) { - console.error('Failed to run action:', e); - notifyError(`Failed to run action "${action.name}"`, e); - } - }} + actionName={runner.outputModal?.actionName ?? ''} + isStopping={runner.outputModal?.isStopping} + onClose={() => runner.closeOutputModal()} + onRunAgain={() => runner.runAgain()} {onNoteCreated} /> - - diff --git a/apps/staged/src/lib/features/branches/branchCardHelpers.ts b/apps/staged/src/lib/features/branches/branchCardHelpers.ts index 2f10dad64..e6a739e65 100644 --- a/apps/staged/src/lib/features/branches/branchCardHelpers.ts +++ b/apps/staged/src/lib/features/branches/branchCardHelpers.ts @@ -1,4 +1,3 @@ -import type { ProjectAction } from '../../api/commands'; import type { PullState } from '../../stores/pullState.svelte'; import type { PushState } from '../../stores/pushState.svelte'; import type { PipelineExecution } from '../../types'; @@ -120,54 +119,6 @@ export function createQueuedSessionCanceller(deps: { }; } -export function groupActionsByType(actions: ProjectAction[]): Record { - const groups: Record = { - prerun: [], - run: [], - build: [], - format: [], - check: [], - test: [], - cleanUp: [], - }; - - for (const action of actions) { - if (groups[action.actionType]) { - groups[action.actionType].push(action); - } - } - - return groups; -} - -export function getPrimaryRunAction( - groupedActions: Record -): ProjectAction | null { - return groupedActions.run?.[0] ?? null; -} - -export function getRemainingRunActions( - groupedActions: Record -): ProjectAction[] { - return groupedActions.run?.slice(1) ?? []; -} - -export function getPrimaryActionExecution( - runningActions: T[], - primaryRunActionId: string | null -): T | null { - if (!primaryRunActionId) return null; - return runningActions.find((a) => a.actionId === primaryRunActionId) ?? null; -} - -export function getSecondaryRunningActions( - runningActions: T[], - primaryRunActionId: string | null -): T[] { - if (!primaryRunActionId) return runningActions; - return runningActions.filter((a) => a.actionId !== primaryRunActionId); -} - export function getActionTypeLabel(actionType: string): string { switch (actionType) { case 'prerun': From b2ea52a742e11213840ec4df0c371cbbc39bab3e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 5 Aug 2026 12:24:24 +1000 Subject: [PATCH 07/18] feat(repos): wire RepoCard to the shared action runner via run_repo_action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo card's Run button and its more menu's Run item were stubs that toasted "coming soon". Wire the card up as the second consumer of the shared action-runner modules, running through run_repo_action against the repo's main local clone: - The card owns an ActionRunner scoped to repoActionScopeId(repo, subpath), loading actions with listRepoActions and running them with runRepoAction (preferred-agent provider, same as branch runs). The stub Run button becomes RunningActionPills plus PrimaryRunActionButton — live status, alt-click-to-stop, endpoint pill with copy-URL — and the more menu's Run item becomes the shared ActionsSubmenu. Clicking a running pill opens ActionOutputModal with Run Again wired to runner.runAgain(). - ActionOutputModal's branchId prop becomes optional: repo-scoped executions have no branch to attach notes to, so the card omits it and the modal hides the save-selection-as-note affordance (and refuses the save path) when it's absent. BranchCardActionsBar still passes branch.id and is unchanged. - Detection normally runs during project setup, so a cloned repo never attached to a project has an empty action context. Such cards get an explicit Detect Actions affordance (Zap button in the run slot and a more-menu item) that mirrors the settings panel's flow: detect, then persist the suggestions that don't already exist, then broadcast project-actions-changed. Cards listen for that event and for repo-actions-detection (matched on repo+subpath) so detection kicked off on any surface updates every card showing the repo. - Clone gating stays: action lookups, the runner surfaces, and the Actions submenu only exist when hasLocalClone; not-yet-cloned cards keep the Clone button and menu item. Action state hydrates once cloning flips hasLocalClone. The card action rows gain flex-wrap so running pills wrap on narrow cards instead of overflowing. Verified with `just typecheck`, `just fmt-check`, `just test-frontend` (481 passed), and a `VITE_REPOS_UI_ENABLED=true` production build. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../features/actions/ActionOutputModal.svelte | 74 +++---- .../src/lib/features/projects/RepoCard.svelte | 195 +++++++++++++++--- 2 files changed, 208 insertions(+), 61 deletions(-) diff --git a/apps/staged/src/lib/features/actions/ActionOutputModal.svelte b/apps/staged/src/lib/features/actions/ActionOutputModal.svelte index 311d7eead..a5817dbcf 100644 --- a/apps/staged/src/lib/features/actions/ActionOutputModal.svelte +++ b/apps/staged/src/lib/features/actions/ActionOutputModal.svelte @@ -15,6 +15,8 @@ Props: executionId — the execution to display output for + branchId — the branch saved notes attach to; omit for repo-scoped + executions, which hides the save-as-note affordance actionName — name of the action being run onClose — callback to close this modal --> @@ -48,7 +50,7 @@ interface Props { open: boolean; executionId: string; - branchId: string; + branchId?: string; actionName: string; isStopping?: boolean; onClose: () => void; @@ -140,7 +142,7 @@ } async function handleSaveAsNote() { - if (saveState === 'saved') return; + if (!branchId || saveState === 'saved') return; const content = capturedSelection || selectedText || getFullOutputText(); capturedSelection = ''; if (!content) return; @@ -427,40 +429,42 @@ {/if}
- - - + + + {/if} {#if isRunning} {@const isCurrentlyStopping = isStopping || isStoppingDerived} diff --git a/apps/staged/src/lib/features/projects/RepoCard.svelte b/apps/staged/src/lib/features/projects/RepoCard.svelte index 3b7e4e429..42184e346 100644 --- a/apps/staged/src/lib/features/projects/RepoCard.svelte +++ b/apps/staged/src/lib/features/projects/RepoCard.svelte @@ -5,9 +5,17 @@ The card is the full repo path (rendered by the shared RepoLabel, wrapped over as many lines as it needs) above a row of actions: a labelled "Add project" - button on the left, then — right-aligned — run or clone, the pin toggle, and a - more menu carrying every repo action plus the local-clone openers. Card tint, - border and accent all come from the repo's badge hue. + button on the left, then — right-aligned — the action-runner surfaces (running + pills and the primary run button) or a clone button, the pin toggle, and a + more menu carrying every repo action, an Actions submenu, and the local-clone + openers. Card tint, border and accent all come from the repo's badge hue. + + Action runs go through the shared ActionRunner, scoped to the synthetic + repoActionScopeId and executed by run_repo_action against the repo's main + local clone — so they require the clone (cards without one show Clone + instead). A cloned repo whose action context is empty (detection normally + runs during project setup) gets a Detect Actions affordance in the run slot + and the more menu. Pass `reorderable` to make the card a drag-to-reorder handle (the sidebar's pinned list); the drag callbacks are only wired up in that mode. Pass @@ -15,15 +23,17 @@ unpin in the more menu only). -->