diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx index 7d297677..676a8093 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx @@ -153,6 +153,14 @@ export function CreateLaneDialog({ const allBranches = createBranches; const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) ?? null; const usedColors = React.useMemo(() => colorsInUse(lanes), [lanes]); + const usedColorOwners = React.useMemo(() => { + const map = new Map(); + for (const candidate of lanes) { + if (candidate.archivedAt || !candidate.color) continue; + map.set(candidate.color.toLowerCase(), candidate.name); + } + return map; + }, [lanes]); const [pickerOpen, setPickerOpen] = React.useState(false); const [issuePickerOpen, setIssuePickerOpen] = React.useState(false); @@ -282,6 +290,7 @@ export function CreateLaneDialog({ value={selectedColor} onChange={setSelectedColor} usedColors={usedColors} + usedColorOwners={usedColorOwners} swatchSize={20} /> diff --git a/apps/desktop/src/renderer/components/lanes/LaneColorPicker.tsx b/apps/desktop/src/renderer/components/lanes/LaneColorPicker.tsx index dde8a978..d66e19e0 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneColorPicker.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneColorPicker.tsx @@ -1,10 +1,11 @@ import React from "react"; -import { LANE_COLOR_PALETTE } from "./laneColorPalette"; +import { LANE_CLASSIC_COLORS, LANE_RAINBOW_COLORS, type LaneColor } from "./laneColorPalette"; type Props = { value: string | null | undefined; onChange: (color: string | null) => void; usedColors?: Set; + usedColorOwners?: Map; showClear?: boolean; swatchSize?: number; }; @@ -13,72 +14,194 @@ export function LaneColorPicker({ value, onChange, usedColors, + usedColorOwners, showClear = true, swatchSize = 22, }: Props) { const selected = value?.toLowerCase() ?? null; + + return ( +
+ + onChange(null)} swatchSize={swatchSize} /> + ) : null} + /> +
+ ); +} + +function SwatchGroup({ + label, + colors, + selected, + usedColors, + usedColorOwners, + onChange, + swatchSize, + trailing, +}: { + label: string; + colors: readonly LaneColor[]; + selected: string | null; + usedColors?: Set; + usedColorOwners?: Map; + onChange: (color: string) => void; + swatchSize: number; + trailing?: React.ReactNode; +}) { return ( -
- {LANE_COLOR_PALETTE.map((entry) => { - const isSelected = selected === entry.hex.toLowerCase(); - const isTaken = !isSelected && (usedColors?.has(entry.hex.toLowerCase()) ?? false); - return ( - + + ) : null} -
+ + ); +} + +function ClearButton({ + selected, + onClear, + swatchSize, +}: { + selected: boolean; + onClear: () => void; + swatchSize: number; +}) { + return ( + ); } diff --git a/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx b/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx index d3a239ed..831af1bd 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx @@ -3,7 +3,7 @@ import type { LaneSummary } from "../../../shared/types"; import { revealLabel } from "../../lib/platform"; import { useAppStore } from "../../state/appStore"; import { COLORS, MONO_FONT } from "./laneDesignTokens"; -import { LANE_COLOR_PALETTE, colorsInUse } from "./laneColorPalette"; +import { LANE_CLASSIC_COLORS, LANE_RAINBOW_COLORS, colorsInUse, type LaneColor } from "./laneColorPalette"; const menuItemStyle: React.CSSProperties = { display: "block", @@ -293,7 +293,16 @@ function ColorSwatchRow({ }) { const [error, setError] = React.useState(null); const [busy, setBusy] = React.useState(false); - const used = React.useMemo(() => colorsInUse(Array.from(lanesById.values()), ctxLane.id), [lanesById, ctxLane.id]); + const lanesArray = React.useMemo(() => Array.from(lanesById.values()), [lanesById]); + const used = React.useMemo(() => colorsInUse(lanesArray, ctxLane.id), [lanesArray, ctxLane.id]); + const owners = React.useMemo(() => { + const map = new Map(); + for (const lane of lanesArray) { + if (lane.archivedAt || lane.id === ctxLane.id || !lane.color) continue; + map.set(lane.color.toLowerCase(), lane.name); + } + return map; + }, [lanesArray, ctxLane.id]); const currentLower = ctxLane.color?.toLowerCase() ?? null; const apply = async (next: string | null) => { @@ -310,48 +319,37 @@ function ColorSwatchRow({ }; return ( -
-
- {LANE_COLOR_PALETTE.map((entry) => { - const isSelected = currentLower === entry.hex.toLowerCase(); - const isTaken = !isSelected && used.has(entry.hex.toLowerCase()); - return ( - ) : null} -
+ /> {error ? ( -
{error}
+
{error}
) : null}
); } + +function SwatchGroup({ + label, + colors, + selectedLower, + used, + owners, + busy, + onPick, + trailing, +}: { + label: string; + colors: readonly LaneColor[]; + selectedLower: string | null; + used: Set; + owners: Map; + busy: boolean; + onPick: (hex: string) => void; + trailing?: React.ReactNode; +}) { + return ( +
+ + {label} + +
+ {colors.map((entry) => { + const lower = entry.hex.toLowerCase(); + const isSelected = selectedLower === lower; + const isTaken = !isSelected && used.has(lower); + const owner = isTaken ? owners.get(lower) ?? null : null; + const title = isTaken + ? owner + ? `${entry.name} — used by ${owner}` + : `${entry.name} — in use` + : entry.name; + return ( + + ); + })} + {trailing} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts index 2920f9d5..c049697c 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildLaneActionClearedSearch, githubPrMatchesCurrentBranch, laneHasAncestor, lanePrMatchesCurrentBranch, @@ -11,6 +12,7 @@ import { selectGithubLanePrTag, selectLaneTabPrTag, selectLanePrTag, + shouldApplyLaneIdsDeepLink, sortLaneListRows, } from "./lanePageModel"; import { shouldMountGitActionsPane } from "./LanesPage"; @@ -169,6 +171,25 @@ describe("resolveLaneIdsDeepLinkSelection", () => { consumedSignature: null, })).toBeNull(); }); + + it("does not apply laneIds while an action deep link is being handled", () => { + expect(shouldApplyLaneIdsDeepLink({ + action: "batch", + laneIdsRaw: "lane-a,lane-b", + })).toBe(false); + }); + + it("applies laneIds when no action deep link is present", () => { + expect(shouldApplyLaneIdsDeepLink({ + action: null, + laneIdsRaw: "lane-a,lane-b", + })).toBe(true); + }); + + it("scrubs action lane params while preserving unrelated query params", () => { + expect(buildLaneActionClearedSearch("?action=batch&laneId=lane-a&laneIds=lane-a,lane-b&inspectorTab=work")) + .toBe("?inspectorTab=work"); + }); }); describe("planLaneDeleteBatches", () => { diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 188a00f5..b8bc1dfb 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -27,6 +27,7 @@ import { HelpChip } from "../onboarding/HelpChip"; import { useOnboardingStore } from "../../state/onboardingStore"; import { useDialogBus } from "../../lib/useDialogBus"; import { + buildLaneActionClearedSearch, parseLaneIdsParam, laneHasAncestor, planLaneDeleteBatches, @@ -35,6 +36,7 @@ import { resolveLaneIdsDeepLinkSelection, resolveVisibleLaneIds, selectLaneTabPrTag, + shouldApplyLaneIdsDeepLink, sortLaneListRows, type LaneTabPrTag, } from "./lanePageModel"; @@ -276,6 +278,7 @@ export function LanesPage() { const [activeLaneIds, setActiveLaneIds] = useState([]); const [pinnedLaneIds, setPinnedLaneIds] = useState>(new Set()); + const [pulsingLaneId, setPulsingLaneId] = useState(null); const [gridResetKey, setGridResetKey] = useState(0); const [laneFilter, setLaneFilter] = useState(""); const [laneStatusFilter, setLaneStatusFilter] = useState<"all" | "running" | "awaiting-input" | "ended">("all"); @@ -1966,7 +1969,8 @@ export function LanesPage() { }, [urlLaneDeeplinks.action]); // ?action=manage&laneId=X opens ManageLaneDialog for that lane. Used by other - // pages (graph, PR cleanup) to route through the canonical delete surface. + // pages (graph, PR cleanup, Work-tab lane right-click) to route through the + // canonical delete surface. useEffect(() => { if (urlLaneDeeplinks.action !== "manage") return; const targetId = urlLaneDeeplinks.laneId; @@ -1980,15 +1984,81 @@ export function LanesPage() { setDeleteRemoteName("origin"); setDeleteConfirmText(""); setManageOpen(true); + setPulsingLaneId(targetId); // Scrub the action param so refreshes don't re-open. - const next = new URLSearchParams(location.search); - next.delete("action"); - navigate(`${location.pathname}?${next.toString()}`, { replace: true }); + navigate(`${location.pathname}${buildLaneActionClearedSearch(location.search)}`, { replace: true }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [urlLaneDeeplinks.action, urlLaneDeeplinks.laneId, lanesById, deletingLaneIds]); + // Clear the pulse marker shortly after it is set so the animation can replay. useEffect(() => { - if (!urlLaneDeeplinks.laneIdsRaw) return; + if (!pulsingLaneId) return; + const t = window.setTimeout(() => setPulsingLaneId(null), 700); + return () => window.clearTimeout(t); + }, [pulsingLaneId]); + + // Handle additional Work-tab right-click actions that route to the Lanes tab. + useEffect(() => { + const action = urlLaneDeeplinks.action; + if (!action) return; + const laneId = urlLaneDeeplinks.laneId; + let handled = false; + if (action === "adopt" && laneId) { + const lane = lanesById.get(laneId); + if (lane && lane.laneType === "attached" && !deletingLaneIds.has(laneId)) { + selectLane(laneId); + reopenAdoptHint(); + requestAdoptAttachedLane(laneId); + handled = true; + } + } else if (action === "split-open" && laneId) { + if (!deletingLaneIds.has(laneId) && lanesById.has(laneId)) { + const pinned = Array.from(pinnedLaneIds).filter((id) => lanesById.has(id)); + setActiveLaneIds((prev) => mergeUnique(prev, [laneId], pinned)); + selectLane(laneId); + handled = true; + } + } else if (action === "split-remove" && laneId) { + removeSplitLane(laneId); + handled = true; + } else if (action === "split-close-others" && laneId) { + const pinned = Array.from(pinnedLaneIds).filter((id) => lanesById.has(id)); + setActiveLaneIds(mergeUnique([laneId], pinned)); + selectLane(laneId); + handled = true; + } else if (action === "select-all") { + const allIds = filteredLanes.map((lane) => lane.id); + setActiveLaneIds(allIds); + handled = true; + } else if (action === "batch") { + const raw = urlLaneDeeplinks.laneIdsRaw; + if (raw) { + const ids = raw.split(",").map((id) => id.trim()).filter(Boolean); + if (ids.length > 0) { + openBatchManage(ids); + handled = true; + } + } + } + if (!handled) return; + if (laneId) setPulsingLaneId(laneId); + navigate(`${location.pathname}${buildLaneActionClearedSearch(location.search)}`, { replace: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + urlLaneDeeplinks.action, + urlLaneDeeplinks.laneId, + urlLaneDeeplinks.laneIdsRaw, + lanesById, + deletingLaneIds, + pinnedLaneIds, + filteredLanes, + ]); + + useEffect(() => { + if (!shouldApplyLaneIdsDeepLink({ + action: urlLaneDeeplinks.action, + laneIdsRaw: urlLaneDeeplinks.laneIdsRaw, + })) return; const laneIdsSelection = resolveLaneIdsDeepLinkSelection({ laneIdsRaw: urlLaneDeeplinks.laneIdsRaw, inspectorTabParam: urlLaneDeeplinks.inspectorTab, @@ -2005,9 +2075,17 @@ export function LanesPage() { setLaneInspectorTab(valid[0], urlLaneDeeplinks.inspectorTab as LaneInspectorTab); } } - }, [availableLaneIds, selectLane, setLaneInspectorTab, urlLaneDeeplinks.laneIdsRaw, urlLaneDeeplinks.inspectorTab]); + }, [ + availableLaneIds, + selectLane, + setLaneInspectorTab, + urlLaneDeeplinks.action, + urlLaneDeeplinks.laneIdsRaw, + urlLaneDeeplinks.inspectorTab, + ]); useEffect(() => { + if (urlLaneDeeplinks.action) return; if (urlLaneDeeplinks.laneIdsRaw) return; consumedLaneIdsDeepLinkSignatureRef.current = null; const laneId = urlLaneDeeplinks.laneId; @@ -2021,6 +2099,7 @@ export function LanesPage() { setLaneInspectorTab(laneId, urlLaneDeeplinks.inspectorTab as LaneInspectorTab); } }, [ + urlLaneDeeplinks.action, urlLaneDeeplinks.laneIdsRaw, urlLaneDeeplinks.laneId, urlLaneDeeplinks.focus, @@ -3018,7 +3097,7 @@ export function LanesPage() { role="button" tabIndex={isDeleting ? -1 : 0} aria-disabled={isDeleting} - className="group flex items-center gap-2 shrink-0" + className={`group flex items-center gap-2 shrink-0${pulsingLaneId === lane.id ? " ade-lane-row-pulse" : ""}`} style={{ position: "relative", padding: "0 16px", diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx index 23b56862..9c1555be 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx @@ -578,6 +578,14 @@ function AppearanceSection({ const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const used = React.useMemo(() => colorsInUse(allLanes, lane.id), [allLanes, lane.id]); + const usedOwners = React.useMemo(() => { + const map = new Map(); + for (const candidate of allLanes) { + if (candidate.archivedAt || candidate.id === lane.id || !candidate.color) continue; + map.set(candidate.color.toLowerCase(), candidate.name); + } + return map; + }, [allLanes, lane.id]); const currentName = laneColorName(lane.color); const apply = async (next: string | null) => { @@ -606,6 +614,7 @@ function AppearanceSection({ value={lane.color} onChange={(next) => { void apply(next); }} usedColors={used} + usedColorOwners={usedOwners} /> {error ? (
{error}
diff --git a/apps/desktop/src/renderer/components/lanes/laneColorPalette.ts b/apps/desktop/src/renderer/components/lanes/laneColorPalette.ts index 50de1aaa..120c9913 100644 --- a/apps/desktop/src/renderer/components/lanes/laneColorPalette.ts +++ b/apps/desktop/src/renderer/components/lanes/laneColorPalette.ts @@ -5,9 +5,9 @@ export type LaneColor = { name: string; }; -// 12 primary-leaning hexes. The first 8 are kept compatible with the legacy -// LANE_ACCENT_COLORS array used as an index-based fallback for unassigned lanes. -export const LANE_COLOR_PALETTE: readonly LaneColor[] = [ +// The first 8 classic colors are also the legacy LANE_ACCENT_COLORS fallback +// used as an index-based accent for unassigned lanes. +export const LANE_CLASSIC_COLORS: readonly LaneColor[] = [ { hex: "#a78bfa", name: "Violet" }, { hex: "#60a5fa", name: "Blue" }, { hex: "#34d399", name: "Emerald" }, @@ -20,8 +20,26 @@ export const LANE_COLOR_PALETTE: readonly LaneColor[] = [ { hex: "#a3e635", name: "Lime" }, { hex: "#22d3ee", name: "Cyan" }, { hex: "#e879f9", name: "Fuchsia" }, +]; + +// Pure rainbow primaries R O Y G B I V. +export const LANE_RAINBOW_COLORS: readonly LaneColor[] = [ + { hex: "#ef4444", name: "Bright Red" }, + { hex: "#f97316", name: "Bright Orange" }, + { hex: "#facc15", name: "Bright Yellow" }, + { hex: "#22c55e", name: "Bright Green" }, + { hex: "#2563eb", name: "Bright Blue" }, + { hex: "#4f46e5", name: "Indigo" }, + { hex: "#7c3aed", name: "Bright Violet" }, +] as const; + +export const LANE_COLOR_PALETTE: readonly LaneColor[] = [ + ...LANE_CLASSIC_COLORS, + ...LANE_RAINBOW_COLORS, ] as const; +export const LANE_CLASSIC_COUNT = LANE_CLASSIC_COLORS.length; + export const LANE_FALLBACK_COLORS: readonly string[] = LANE_COLOR_PALETTE .slice(0, 8) .map((c) => c.hex); diff --git a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts index eaebd7bd..6c7fce15 100644 --- a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts +++ b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts @@ -61,6 +61,19 @@ export function parseLaneIdsParam(value: string | null): string[] { .filter(Boolean); } +export function shouldApplyLaneIdsDeepLink(args: { action: string | null; laneIdsRaw: string | null }): boolean { + return !args.action && parseLaneIdsParam(args.laneIdsRaw).length > 0; +} + +export function buildLaneActionClearedSearch(search: string): string { + const next = new URLSearchParams(search); + next.delete("action"); + next.delete("laneId"); + next.delete("laneIds"); + const query = next.toString(); + return query ? `?${query}` : ""; +} + export function planLaneDeleteBatches>(lanes: T[]): T[][] { const lanesById = new Map(lanes.map((lane) => [lane.id, lane] as const)); const remaining = new Set(lanesById.keys()); diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 6781e4b0..80911b53 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -12,6 +12,7 @@ import { cn } from "../ui/cn"; import { branchNameFromRef } from "../prs/shared/laneBranchTargets"; import { laneSurfaceTint } from "../lanes/laneDesignTokens"; import { isChatToolType } from "../../lib/sessions"; +import { useWorkLaneContextMenu } from "./useWorkLaneContextMenu"; const STATUS_FILTER_OPTIONS: Array<{ value: WorkStatusFilter; label: string; description: string }> = [ { value: "all", label: "All", description: "Show sessions in every state." }, @@ -46,6 +47,7 @@ function StickyGroupHeader({ count, collapsed, onToggleCollapsed, + onContextMenu, accentColor, children, subLabel, @@ -57,6 +59,7 @@ function StickyGroupHeader({ count: number; collapsed: boolean; onToggleCollapsed: () => void; + onContextMenu?: (e: React.MouseEvent) => void; accentColor?: string | null; children: React.ReactNode; /** Branch label shown on the right for `variant="lane"` (e.g. from `branchNameFromRef`). */ @@ -83,6 +86,7 @@ function StickyGroupHeader({ borderBottom: isLane ? undefined : "1px solid rgba(255, 255, 255, 0.04)", }} onClick={onToggleCollapsed} + onContextMenu={onContextMenu} data-section-id={sectionId} > {isLane ? ( @@ -211,6 +215,7 @@ export const SessionListPane = React.memo(function SessionListPane({ }) { const navigate = useNavigate(); const orderedLanes = useMemo(() => sortLanesForTabs(lanes), [lanes]); + const { trigger: triggerLaneContextMenu, menu: laneContextMenuPortal } = useWorkLaneContextMenu(); const hasAnySessions = runningFiltered.length + awaitingInputFiltered.length + endedFiltered.length > 0; @@ -495,6 +500,7 @@ export const SessionListPane = React.memo(function SessionListPane({ collapsed={collapsed} accentColor={laneAccent} onToggleCollapsed={() => toggleWorkLaneCollapsed(lane.id)} + onContextMenu={(e) => triggerLaneContextMenu(lane.id, e)} > {renderCards(list)} @@ -778,6 +784,7 @@ export const SessionListPane = React.memo(function SessionListPane({ + {laneContextMenuPortal} ); }); diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx index 0ce3c486..3f7a8852 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.test.tsx @@ -21,6 +21,12 @@ vi.mock("./TerminalView", () => ({ ), })); +// The Work-tab lane context menu hook depends on react-router's useNavigate. +// These tests render WorkViewArea bare (no router), so stub the hook out. +vi.mock("./useWorkLaneContextMenu", () => ({ + useWorkLaneContextMenu: () => ({ trigger: () => {}, menu: null }), +})); + vi.mock("./WorkCliSessionHeader", () => ({ WorkCliSessionHeader: ({ session, diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index a03cce8c..562deab6 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -57,6 +57,7 @@ import { cn } from "../ui/cn"; import { launchProfileForTerminalSession, type LaunchProfile } from "./cliLaunch"; import { buildWorkSessionTilingTree, type TilingPreset } from "./workSessionTiling"; import { laneSurfaceTint } from "../lanes/laneDesignTokens"; +import { useWorkLaneContextMenu } from "./useWorkLaneContextMenu"; function isSessionAwaitingInput(session: TerminalSessionSummary): boolean { return sessionStatusBucket({ @@ -1406,6 +1407,7 @@ export function WorkViewArea({ }; const workEmbeddedChrome = useFloatingPaneEmbeddedChrome(); const glassHeaderDragProps = workEmbeddedChrome?.dragHandleProps ?? {}; + const { trigger: triggerLaneContextMenu, menu: laneContextMenuPortal } = useWorkLaneContextMenu(); const sessionsById = useMemo(() => { const map = new Map(); for (const session of sessions) map.set(session.id, session); @@ -1480,6 +1482,7 @@ export function WorkViewArea({ laneColor={laneAccentColor} maxWidth={120} onClick={gotoLane} + onContextMenu={(e) => triggerLaneContextMenu(session.laneId, e)} /> )} + {laneContextMenuPortal} ); } @@ -1798,6 +1803,7 @@ export function WorkViewArea({ {tabBody} + {laneContextMenuPortal} ); } @@ -1858,6 +1864,10 @@ export function WorkViewArea({ )} style={bandCssVars} onClick={() => toggleTabGroupCollapsed(group.id)} + onContextMenu={(e) => { + if (!laneId) return; + triggerLaneContextMenu(laneId, e); + }} > @@ -1899,6 +1909,10 @@ export function WorkViewArea({ toggleTabGroupCollapsed(group.id); } }} + onContextMenu={(e) => { + if (!laneId) return; + triggerLaneContextMenu(laneId, e); + }} > {group.label} @@ -1970,6 +1984,7 @@ export function WorkViewArea({ {tabBody} + {laneContextMenuPortal} ); } diff --git a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx new file mode 100644 index 00000000..66efdb61 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { useNavigate } from "react-router-dom"; +import { useAppStore } from "../../state/appStore"; +import { LaneContextMenu } from "../lanes/LaneContextMenu"; + +type MenuState = { laneId: string; x: number; y: number }; + +export type LaneContextTrigger = ( + laneId: string, + e: { preventDefault: () => void; clientX: number; clientY: number }, +) => void; + +export function useWorkLaneContextMenu(): { + trigger: LaneContextTrigger; + menu: React.ReactNode; +} { + const navigate = useNavigate(); + const lanes = useAppStore((s) => s.lanes); + const selectLane = useAppStore((s) => s.selectLane); + + const [menuState, setMenuState] = useState(null); + + const lanesById = useMemo(() => { + const map = new Map(); + for (const lane of lanes) map.set(lane.id, lane); + return map; + }, [lanes]); + + const visibleLaneIds = useMemo(() => { + const id = menuState?.laneId; + return id ? [id] : []; + }, [menuState?.laneId]); + + const trigger = useCallback((laneId, e) => { + e.preventDefault(); + setMenuState({ laneId, x: e.clientX, y: e.clientY }); + }, []); + + const close = useCallback(() => setMenuState(null), []); + + useEffect(() => { + if (!menuState) return; + const onPointerDown = () => setMenuState(null); + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [menuState]); + + const goToLanesAction = useCallback( + (laneId: string | null, action: string, extras?: Record) => { + const init: Record = { action, ...(extras ?? {}) }; + if (laneId) init.laneId = laneId; + const params = new URLSearchParams(init); + if (laneId) selectLane(laneId); + void navigate(`/lanes?${params.toString()}`); + }, + [navigate, selectLane], + ); + + const menu = menuState + ? createPortal( + goToLanesAction(laneId, "adopt")} + onManage={(laneId) => goToLanesAction(laneId, "manage")} + onOpenRun={(laneId) => { + selectLane(laneId); + void navigate("/project"); + }} + selectLane={(laneId) => { + if (!laneId) return; + goToLanesAction(laneId, "split-open"); + }} + onRemoveFromSplit={(laneId) => goToLanesAction(laneId, "split-remove")} + onCloseOtherSplits={(keepLaneId) => goToLanesAction(keepLaneId, "split-close-others")} + onSelectAll={() => { + if (!menuState) return; + goToLanesAction(null, "select-all"); + }} + onBatchManage={(laneIds) => { + if (!laneIds.length) return; + goToLanesAction(laneIds[0], "batch", { laneIds: laneIds.join(",") }); + }} + />, + document.body, + ) + : null; + + return { trigger, menu }; +} diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index 8a61aae1..3083d594 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -2774,7 +2774,7 @@ button:active, [role="button"]:active { line-height: 1; letter-spacing: 0.02em; text-transform: uppercase; - color: var(--lane-band-color, var(--color-muted-fg)); + color: var(--lane-band-color, var(--color-fg)); cursor: pointer; user-select: none; opacity: 0.75; @@ -2805,7 +2805,7 @@ button:active, [role="button"]:active { border: none; background: color-mix(in srgb, var(--lane-band-color, var(--color-fg)) 10%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--lane-band-color, var(--color-fg)) 18%, transparent); - color: var(--lane-band-color, var(--color-muted-fg)); + color: var(--lane-band-color, var(--color-fg)); cursor: pointer; transition: background 120ms ease, box-shadow 120ms ease; } @@ -2836,6 +2836,17 @@ button:active, [role="button"]:active { opacity: 0.55; } +/* Pulse used when navigating to a lane row from the Work-tab context menu. */ +@keyframes ade-lane-row-pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-fg) 36%, transparent); } + 60% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-fg) 0%, transparent); } + 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-fg) 0%, transparent); } +} + +.ade-lane-row-pulse { + animation: ade-lane-row-pulse 600ms ease-out; +} + .ade-work-tab-strip-roomy { display: flex; flex-wrap: nowrap; diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index d8156ba5..fef51a60 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -69,13 +69,13 @@ Renderer components: | File | Responsibility | |------|---------------| -| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR. The pure selectors in `lanePageModel.ts` prefer ADE-linked PR rows, then fall back to `prs.getGitHubSnapshot().repoPullRequests` so merged or externally created PRs stay visible by branch match; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat/process buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` in `deleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). Batch deletes run selected child lanes before their selected parents, deleting independent lanes in parallel while blocking a parent if a selected descendant fails. Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures and non-fatal cleanup warnings through `laneActionError`. | -| `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR matching, ADE-vs-GitHub PR tag precedence, deep-link lane selection, create-lane request normalization, delete-start selection fallback, and parent-before-child-safe batch delete planning. | +| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR. The pure selectors in `lanePageModel.ts` prefer ADE-linked PR rows, then fall back to `prs.getGitHubSnapshot().repoPullRequests` so merged or externally created PRs stay visible by branch match; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat/process buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` in `deleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). Batch deletes run selected child lanes before their selected parents, deleting independent lanes in parallel while blocking a parent if a selected descendant fails. Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures and non-fatal cleanup warnings through `laneActionError`. Work-tab action deeplinks scrub `action`, `laneId`, and `laneIds` after handling so modal routing cannot also rewrite split selection state. | +| `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR matching, ADE-vs-GitHub PR tag precedence, deep-link lane selection, action-deeplink query cleanup, create-lane request normalization, delete-start selection fallback, and parent-before-child-safe batch delete planning. | | `renderer/components/lanes/laneUtils.ts` | Pure lane list/filter helpers plus default pane trees, including the work-focused tiling tree used by parallel chat launch deep links. | -| `renderer/components/lanes/laneColorPalette.ts` | Curated 12-swatch lane color palette (`LANE_COLOR_PALETTE`) plus helpers (`getLaneAccent`, `colorsInUse`, `nextAvailableColor`, `laneColorName`). The first 8 hexes form `LANE_FALLBACK_COLORS`, the legacy index-based fallback used for lanes that don't have an explicit color assigned. | +| `renderer/components/lanes/laneColorPalette.ts` | Curated lane color palette split into `LANE_CLASSIC_COLORS` and `LANE_RAINBOW_COLORS`, then combined as `LANE_COLOR_PALETTE`, plus helpers (`getLaneAccent`, `colorsInUse`, `nextAvailableColor`, `laneColorName`). The first 8 classic hexes form `LANE_FALLBACK_COLORS`, the legacy index-based fallback used for lanes that don't have an explicit color assigned. | | `renderer/components/lanes/LaneAccentDot.tsx` | Tiny accent dot used everywhere a lane is mentioned (lane list, tabs, PR rows, AppShell PR toasts). Resolves color via `getLaneAccent` so a lane without an explicit color falls back to a deterministic fallback hex. | -| `renderer/components/lanes/LaneColorPicker.tsx` | Reusable swatch-row picker used inside `CreateLaneDialog` and `ManageLaneDialog`. Disables swatches already in use by other lanes (passed in as `usedColors`) and offers a clear button. | -| `renderer/components/lanes/LaneContextMenu.tsx` | Right-click menu on the lane list. Hosts the inline color swatch row that calls `lanes.updateAppearance` directly, "Reveal/Copy path", manage/adopt/open-in-Run actions, split-tab actions, and batch manage. | +| `renderer/components/lanes/LaneColorPicker.tsx` | Reusable grouped swatch picker used inside `CreateLaneDialog` and `ManageLaneDialog`. Shows Rainbow above Classic, disables swatches already in use by other lanes (passed in as `usedColors`), and offers a clear button. | +| `renderer/components/lanes/LaneContextMenu.tsx` | Right-click menu on the lane list. Hosts the inline grouped color swatches that call `lanes.updateAppearance` directly, "Reveal/Copy path", manage/adopt/open-in-Run actions, split-tab actions, and batch manage. | | `renderer/components/lanes/LaneStackPane.tsx` | Stack graph sidebar, integration source chips, canvas jump | | `renderer/components/lanes/LaneDiffPane.tsx` | Lane diff list + per-file stage/unstage/discard; file content uses shared `AdeDiffViewer` (commit comparisons read-only; working-tree file can be editable when unstaged) | | `renderer/components/lanes/LaneGitActionsPane.tsx` | Commit, stash, fetch, sync, push, recent commits. Stashing includes untracked files when the unstaged set contains untracked paths, and stash restore uses the ordinal `stash@{N}` ref returned by `git stash list`. After commit/stash operations it refreshes changes, lane git status, and git metadata while skipping snapshot decorations (`refreshLanes({ includeStatus: true, includeSnapshots: false })`). Seeds its `autoRebaseStatus` from the `autoRebaseStatusSnapshot` prop that `LanesPage` passes from the lane list (`laneSnapshot.autoRebaseStatus`), so opening a lane does not trigger a per-lane probe. A fallback `refreshAutoRebaseStatus` runs only when the snapshot is `undefined`, after a 3.5 s delay, and only while the document is visible. | @@ -306,9 +306,15 @@ GitHub PR rows in `prs/tabs/GitHubTab.tsx`, the QueueTab member rows, and the post-merge PR toast in `AppShell`. The palette and helpers live in `renderer/components/lanes/laneColorPalette.ts`: -- `LANE_COLOR_PALETTE` — 12 curated hexes, each with a human label +- `LANE_CLASSIC_COLORS` — 12 curated hexes, each with a human label (Violet / Blue / Emerald / Amber / Pink / Orange / Teal / Purple / Red / Lime / Cyan / Fuchsia). +- `LANE_RAINBOW_COLORS` — 7 bright rainbow hexes (red / orange / + yellow / green / blue / indigo / violet). +- `LANE_COLOR_PALETTE` — the combined classic-then-rainbow palette used + by helpers and compatibility fallbacks. `LANE_CLASSIC_COUNT` is + derived from `LANE_CLASSIC_COLORS.length` so picker grouping cannot + drift from the palette definition. - `LANE_FALLBACK_COLORS` — first 8 of the palette, kept stable for the index-based fallback used by `getLaneAccent(lane, fallbackIndex)` for lanes without an explicit color. @@ -432,6 +438,11 @@ open lanes; primary lanes render with a home icon. lane set side-by-side, and clears pinned lanes for that focused view. This is used after parallel chat launch to open every newly-created model lane in the Work inspector. +- Action deep links such as `action=batch` are exclusive with bare + selection links. `LanesPage` handles the action first, then removes + `action`, `laneId`, and `laneIds` from the URL so the normal + single-lane and multi-lane selection effects cannot re-apply stale + selection state. - Parallel chat launch links use `LANES_TILING_WORK_FOCUS_TREE` and a `layoutId` suffix so newly-created comparison lanes emphasize the Work pane without overwriting the user's normal lane cockpit layout. diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 1368fdee..1d941685 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -180,6 +180,9 @@ Renderer surfaces: ended / clear selection). The filter panel is width-constrained by the surrounding Work split, so status/group options wrap in an auto-fit grid and the embedded lane selector can fill its parent. + Lane group headers expose the same lane context menu used by the Work + tab so color, manage, split, and batch actions stay reachable without + leaving the session list. - `apps/desktop/src/renderer/components/terminals/SessionCard.tsx` — per-session card (status dot, title, preview line, tool type, lane, delta chips). Surfaces a small amber warning pip next to the title @@ -197,6 +200,11 @@ Renderer surfaces: into a live runtime when one is still attached, or starts a fresh provider continuation internally and binds it back to the same durable session id. +- `apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx` + — shared Work-tab lane context menu hook. It portals `LaneContextMenu` + over lane bands, lane chips, collapsed lane pills, and grouped session + headers, running inline actions in place and routing modal-bearing lane + actions through `/lanes?action=...`. - `apps/desktop/src/renderer/components/terminals/WorkCliSessionHeader.tsx` — small chat-style header rendered above tracked agent CLI terminals (and their tabs). Shows the provider logo, primary title, status dot, diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index e04d6cb7..bef46e71 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -70,6 +70,11 @@ Lists sessions grouped by one of three modes (controlled by Each group uses a `StickyGroupHeader` with collapsed-state persistence via `workCollapsedLaneIds` / `workCollapsedSectionIds`. +Lane group headers also wire into `useWorkLaneContextMenu`, so right-click +actions are available from the session sidebar: color changes and copy/reveal +run inline, while manage/adopt/batch/split actions route through the Lanes tab +deeplinks. + In `by-lane` mode, any session whose `laneId` is not in the current lanes list is still rendered under its own sticky "orphan lane" group below the active lane groups. The list is built from @@ -141,7 +146,9 @@ Owns the render target for open sessions. Supports three modes tied to - `tabs` — tab-strip + single `SessionSurface` for the active tab, plus a "New Chat" button in the tab strip. A second sub-mode (`hasGroupedTabs`) - renders lane-grouped tab chips with per-group collapse. + renders lane-grouped tab chips with per-group collapse. Lane group chips use + `useWorkLaneContextMenu` for the same color/manage/split/batch actions as + the Lanes tab. - `grid` — tiled pane layout. Each session becomes a `PaneConfig` that mounts a `SessionSurface` in `grid-tile` variant. The tiling tree is rendered by `PaneTilingLayout`, seeded by