From 8003f7edd7ae83761febe9d8e645e971500454cc Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:27:10 -0400 Subject: [PATCH 01/12] Improve multi-machine project UX - Fetch icons and attachment bytes over the authenticated WebSocket RPC for every saved environment, not just relay-paired ones: a saved computer's HTTP routes are cross-origin and a browser img/fetch cannot carry the WebSocket credential, so remote icons and chat images 401'd. - ProjectFavicon is RPC-only for all environments and gains a monogram fallback (tinted first letter, hue hashed from the checkout path) for projects with no icon file; the neutral folder holds while the fetch is pending. - The composer's Switch project menu lists logical projects via the new useSidebarProjectSnapshots hook: one row per repository across machines, with a machine label on remote-only entries. - Thread rows (live and wrapped) show the machine name beside the cloud icon once the visible list spans more than one machine. - The sidebar scope menu grows a Machine section (persisted in uiStateStore) filtering threads, drafts, wrapped entries, and counts. --- apps/web/src/components/CommandPalette.tsx | 3 + .../src/components/NoActiveThreadState.tsx | 1 + apps/web/src/components/ProjectFavicon.tsx | 137 +++++++++++------- apps/web/src/components/RecentThreadsList.tsx | 6 +- apps/web/src/components/Sidebar.tsx | 119 +++++++++++---- .../src/components/chat/DraftEmptyState.tsx | 50 ++++++- .../chat/FirstRunSetupCard.browser.tsx | 2 +- .../src/components/chat/FirstRunSetupCard.tsx | 6 +- .../src/components/chat/MessagesTimeline.tsx | 15 +- .../chat/ProviderModelPicker.browser.tsx | 2 +- .../settings/AgentInstructionsSettings.tsx | 9 +- .../settings/ExtensionsSettings.tsx | 9 +- .../settings/SettingsPanels.browser.tsx | 2 +- .../components/settings/SettingsPanels.tsx | 8 +- apps/web/src/components/sidebar/InboxRows.tsx | 116 +++++++++------ .../components/sidebar/ProjectScopeMenu.tsx | 75 +++++++++- .../sidebar/SidebarDrafts.browser.tsx | 2 + .../src/components/sidebar/SidebarDrafts.tsx | 17 +++ .../components/sidebar/ThreadHoverCard.tsx | 8 +- .../SourceControlPanel.browser.tsx | 2 +- apps/web/src/environments/runtime/catalog.ts | 18 ++- apps/web/src/environments/runtime/index.ts | 2 +- .../src/hooks/useSidebarProjectSnapshots.ts | 55 +++++++ apps/web/src/lib/attachmentPreviewQuery.ts | 19 ++- apps/web/src/lib/projectReactQuery.ts | 9 +- apps/web/src/sidebarProjectGrouping.test.ts | 110 ++++++++++++++ apps/web/src/sidebarProjectGrouping.ts | 35 ++++- apps/web/src/uiStateStore.test.ts | 21 +++ apps/web/src/uiStateStore.ts | 19 ++- 29 files changed, 705 insertions(+), 172 deletions(-) create mode 100644 apps/web/src/hooks/useSidebarProjectSnapshots.ts create mode 100644 apps/web/src/sidebarProjectGrouping.test.ts diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8e7063c91..8b289790c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -854,6 +854,7 @@ function OpenCommandPaletteDialog() { ), @@ -871,6 +872,7 @@ function OpenCommandPaletteDialog() { ), @@ -918,6 +920,7 @@ function OpenCommandPaletteDialog() { ); diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 089b0462e..d5be2760f 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -242,6 +242,7 @@ export function NoActiveThreadState() { {project.name} diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 225de6d9d..b4a546dd9 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,77 +1,114 @@ import type { EnvironmentId } from "@threadlines/contracts"; import { FolderIcon } from "lucide-react"; -import { useState } from "react"; +import type { CSSProperties } from "react"; import { useQuery } from "@tanstack/react-query"; -import { environmentUsesRelayTransport, resolveEnvironmentHttpUrl } from "../environments/runtime"; +import { cn } from "../lib/utils"; import { projectFaviconQueryOptions } from "../lib/projectReactQuery"; -const PROJECT_FAVICON_RESOLVER_VERSION = "3"; -const loadedProjectFaviconSrcs = new Set(); +/** + * A stable hue for a project, derived from its identity string (the cwd). The + * same checkout keeps the same tint on every surface and across reloads, and + * neighbouring paths land far apart because the multiplier spreads them. + */ +function projectMonogramHue(identity: string): number { + let hash = 0; + for (let index = 0; index < identity.length; index += 1) { + hash = (hash * 31 + identity.charCodeAt(index)) % 360_000; + } + return hash % 360; +} + +function projectMonogramGlyph(name: string): string | null { + const first = name.trim().at(0); + return first ? first.toUpperCase() : null; +} + +/** + * The stand-in for a project with no icon of its own: one tinted letter. + * + * Quiet by design — low chroma against the sidebar, no border and no shadow — + * so a column of them reads as texture that distinguishes rows, not as a row + * of colourful avatars. + */ +function ProjectMonogram(props: { glyph: string; hue: number; className: string | undefined }) { + return ( + + ); +} +/** + * The project's own icon, fetched over the environment's WebSocket RPC. + * + * One transport for every environment, primary or saved. A saved environment's + * `/api/project-favicon` route is cross-origin and authenticated over the + * WebSocket, so the browser cannot fetch it from an `` at all; and even + * locally the HTTP route answers a missing icon with a fallback SVG the client + * can't tell apart from a real one, which is what the monogram needs to know. + * Icons are a few KB and the query holds them for an hour, so this costs one + * round trip per checkout. + */ export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; + /** Enables the monogram fallback for projects with no icon of their own. */ + name?: string; className?: string; }) { - // Relay-paired environments (phonelink) can't reach the favicon HTTP - // route — the relay carries only the WebSocket — so fetch the icon bytes - // over RPC and render them as a data URL instead. - const usesRelay = environmentUsesRelayTransport(input.environmentId); const faviconQuery = useQuery( projectFaviconQueryOptions({ environmentId: input.environmentId, cwd: input.cwd, - enabled: usesRelay, + enabled: input.cwd.length > 0, }), ); - const src = (() => { - if (usesRelay) { - return faviconQuery.data ?? null; - } - try { - return resolveEnvironmentHttpUrl({ - environmentId: input.environmentId, - pathname: "/api/project-favicon", - searchParams: { cwd: input.cwd, v: PROJECT_FAVICON_RESOLVER_VERSION }, - }); - } catch { - return null; - } - })(); - const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", - ); - // Data URLs carry their bytes inline, so skip the load-tracking dance the - // HTTP path needs to avoid flashing the fallback while the request runs. - const isLoaded = - src !== null && ((src.startsWith("data:") && status !== "error") || status === "loaded"); + const src = faviconQuery.data ?? null; + if (src) { + return ( + + ); + } + + // While the fetch is still out, the project may yet have a real icon, and a + // monogram that gets replaced by it reads as wrong-then-right. Hold the + // neutral folder until "no icon" is an answer, not a guess. + if (faviconQuery.isLoading) { + return ( + + ); + } - if (!src) { + const glyph = input.name ? projectMonogramGlyph(input.name) : null; + if (glyph) { return ( - ); } return ( - <> - {!isLoaded ? ( - - ) : null} - { - loadedProjectFaviconSrcs.add(src); - setStatus("loaded"); - }} - onError={() => setStatus("error")} - /> - + ); } diff --git a/apps/web/src/components/RecentThreadsList.tsx b/apps/web/src/components/RecentThreadsList.tsx index 100728a41..8162cd64e 100644 --- a/apps/web/src/components/RecentThreadsList.tsx +++ b/apps/web/src/components/RecentThreadsList.tsx @@ -108,7 +108,11 @@ export function RecentThreadsList({ ) : project ? ( - + {project.name} ) : null} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fe1497bef..fdddef3f9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -25,6 +25,7 @@ import { } from "@threadlines/client-runtime"; import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { usePrimaryEnvironmentId } from "../environments/primary"; +import type { EnvironmentId } from "@threadlines/contracts"; import { isElectron } from "../env"; import { APP_BASE_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; @@ -115,7 +116,8 @@ import { useFrozenOpenDraftRow, type SidebarDraftProjectInfo, } from "./sidebar/SidebarDrafts"; -import { ProjectScopeMenu } from "./sidebar/ProjectScopeMenu"; +import { ProjectScopeMenu, type EnvironmentScopeOption } from "./sidebar/ProjectScopeMenu"; +import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { SidebarHoverCardGroup } from "./sidebar/hoverCard"; import { ThreadHoverCardProvider } from "./sidebar/ThreadHoverCard"; import { resolveThreadActionProjectRef, startNewGeneralChatThread } from "../lib/chatThreadActions"; @@ -132,11 +134,8 @@ import { useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; import type { SidebarThreadSummary } from "../types"; -import { - buildPhysicalToLogicalProjectKeyMap, - buildSidebarProjectSnapshots, - type SidebarProjectSnapshot, -} from "../sidebarProjectGrouping"; +import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; +import { useSidebarProjectSnapshots } from "~/hooks/useSidebarProjectSnapshots"; import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; @@ -427,6 +426,8 @@ export default function Sidebar() { const doneThreadOverlays = useUiStateStore((store) => store.doneThreadOverlays); const inboxProjectScopeKey = useUiStateStore((store) => store.inboxProjectScopeKey); const setInboxProjectScope = useUiStateStore((store) => store.setInboxProjectScope); + const inboxEnvironmentScopeId = useUiStateStore((store) => store.inboxEnvironmentScopeId); + const setInboxEnvironmentScope = useUiStateStore((store) => store.setInboxEnvironmentScope); const navigate = useNavigate(); const router = useRouter(); const pathname = useLocation({ select: (loc) => loc.pathname }); @@ -530,26 +531,7 @@ export default function Sidebar() { ), [projects], ); - const sidebarProjects = useMemo( - () => - buildSidebarProjectSnapshots({ - projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => { - const rt = savedEnvironmentRuntimeById[environmentId]; - const saved = savedEnvironmentRegistry[environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? null; - }, - }), - [ - projects, - projectGroupingSettings, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ], - ); + const sidebarProjects = useSidebarProjectSnapshots(); const sidebarProjectByKey = useMemo( () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), [sidebarProjects], @@ -660,6 +642,52 @@ export default function Sidebar() { ? inboxProjectScopeKey : null; + // Every machine the inbox knows about: this device, plus whatever has been + // added under "Add computer". Ordered with this device first, the rest by + // name, so the list does not reshuffle as machines connect and drop. + const environmentScopeOptions = useMemo(() => { + const resolveLabel = (environmentId: EnvironmentId, isPrimary: boolean) => + resolveEnvironmentOptionLabel({ + isPrimary, + environmentId, + runtimeLabel: savedEnvironmentRuntimeById[environmentId]?.descriptor?.label ?? null, + savedLabel: savedEnvironmentRegistry[environmentId]?.label ?? null, + }); + const savedOptions = Object.values(savedEnvironmentRegistry) + .filter((record) => record.environmentId !== primaryEnvironmentId) + .map((record) => ({ + environmentId: record.environmentId, + label: resolveLabel(record.environmentId, false), + isPrimary: false, + })) + .toSorted((left, right) => left.label.localeCompare(right.label)); + return primaryEnvironmentId === null + ? savedOptions + : [ + { + environmentId: primaryEnvironmentId, + label: resolveLabel(primaryEnvironmentId, true), + isPrimary: true, + }, + ...savedOptions, + ]; + }, [primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById]); + // A machine that has been removed since the filter was set stops filtering, + // rather than hiding the whole inbox behind a scope nothing can match. + const scopedEnvironmentIdValue = + inboxEnvironmentScopeId !== null && + environmentScopeOptions.length > 1 && + environmentScopeOptions.some((option) => option.environmentId === inboxEnvironmentScopeId) + ? inboxEnvironmentScopeId + : null; + const machineScopedEntries = useMemo( + () => + scopedEnvironmentIdValue === null + ? entries + : entries.filter((entry) => entry.thread.environmentId === scopedEnvironmentIdValue), + [entries, scopedEnvironmentIdValue], + ); + // Everything a draft row needs about its project, resolved once here: a // draft carries a scoped project ref, and the rows want the grouped display // name, the checkout the favicon comes from, and the logical key the inbox @@ -698,6 +726,7 @@ export default function Sidebar() { store, projectInfoByScopedRef: draftProjectInfoByScopedRef, scopedProjectKey: scopedProjectKeyValue, + scopedEnvironmentId: scopedEnvironmentIdValue, routeDraftId, frozenOpenDraftRow, }), @@ -706,7 +735,7 @@ export default function Sidebar() { const scopeOptions = useMemo(() => { const lastActivityMsByKey = new Map(); const needsYouCountByKey = new Map(); - for (const entry of entries) { + for (const entry of machineScopedEntries) { const activityAt = toSortableTimestamp( entry.thread.latestUserMessageAt ?? entry.thread.updatedAt ?? entry.thread.createdAt, @@ -729,13 +758,13 @@ export default function Sidebar() { lastActivityMsByKey, needsYouCountByKey, }); - }, [entries, sidebarProjects]); + }, [machineScopedEntries, sidebarProjects]); const { liveEntries, doneEntries } = useMemo(() => { const scoped = scopedProjectKeyValue === null - ? entries - : entries.filter((entry) => entry.projectKey === scopedProjectKeyValue); + ? machineScopedEntries + : machineScopedEntries.filter((entry) => entry.projectKey === scopedProjectKeyValue); const entryByThreadKey = new Map(scoped.map((entry) => [entry.threadKey, entry] as const)); const lookup = (thread: SidebarThreadSummary) => entryByThreadKey.get(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))!; @@ -751,7 +780,20 @@ export default function Sidebar() { ), ).map(lookup); return { liveEntries, doneEntries }; - }, [doneThreadOverlays, entries, scopedProjectKeyValue]); + }, [doneThreadOverlays, machineScopedEntries, scopedProjectKeyValue]); + + // A machine name on every row is noise while there is only one machine in + // the list -- and while a machine filter is on, every row is that machine. + const showThreadEnvironmentLabels = useMemo(() => { + const environmentIds = new Set(); + for (const entry of [...liveEntries, ...doneEntries]) { + environmentIds.add(entry.thread.environmentId); + if (environmentIds.size > 1) { + return true; + } + } + return false; + }, [doneEntries, liveEntries]); // Volume is managed by folding, not by flattening rows: quiet threads past // the limit fold away, and anything with a status stays put. @@ -791,7 +833,7 @@ export default function Sidebar() { useEffect(() => { setRevealedLiveCount(0); setRevealedDoneCount(0); - }, [scopedProjectKeyValue]); + }, [scopedEnvironmentIdValue, scopedProjectKeyValue]); const orderedThreadKeys = useMemo( () => [ @@ -1256,6 +1298,13 @@ export default function Sidebar() { [setInboxProjectScope], ); + const handleEnvironmentScopeChange = useCallback( + (environmentId: string | null) => { + setInboxEnvironmentScope(environmentId); + }, + [setInboxEnvironmentScope], + ); + const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -1527,6 +1576,9 @@ export default function Sidebar() { projectByKey={sidebarProjectByKey} scopedProjectKey={scopedProjectKeyValue} onScopeChange={handleScopeChange} + environmentOptions={environmentScopeOptions} + scopedEnvironmentId={scopedEnvironmentIdValue} + onEnvironmentScopeChange={handleEnvironmentScopeChange} onAddProject={openAddProjectCommandPalette} onNewThread={handleComposeClick} newThreadShortcutLabel={newThreadShortcutLabel} @@ -1537,6 +1589,7 @@ export default function Sidebar() { + orderSnapshotsByProjectRefs({ + snapshots: projectSnapshots, + orderedProjectRefs: orderedProjects.map((project) => + scopeProjectRef(project.environmentId, project.id), + ), + }), + [orderedProjects, projectSnapshots], + ); return (
@@ -86,6 +102,7 @@ export function DraftEmptyState({ ) : null} {targetName} @@ -112,20 +129,39 @@ export function DraftEmptyState({ ) : null} Switch project - {orderedProjects.map((project) => { - const projectRef = scopeProjectRef(project.environmentId, project.id); + {menuSnapshots.map((snapshot) => { + const projectRef = scopeProjectRef(snapshot.environmentId, snapshot.id); const isCurrentProject = - currentProjectKey !== null && scopedProjectKey(projectRef) === currentProjectKey; + currentProjectKey !== null && + snapshot.memberProjectRefs.some( + (memberRef) => scopedProjectKey(memberRef) === currentProjectKey, + ); + // A project that only exists on another machine says so; one + // that lives on both says nothing, because picking it here does + // not pick a machine. + const remoteOnlyLabel = + snapshot.environmentPresence === "remote-only" + ? snapshot.remoteEnvironmentLabels.join(", ") + : ""; return ( { void handleNewThread(projectRef); }} - title={project.cwd} + title={snapshot.cwd} > - - {project.name} + + {snapshot.displayName} + {remoteOnlyLabel ? ( + + {remoteOnlyLabel} + + ) : null} {isCurrentProject ? ( ) : null} diff --git a/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx b/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx index ac1de0335..cbf149b2b 100644 --- a/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx +++ b/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx @@ -90,7 +90,7 @@ vi.mock("../../environments/runtime", () => { } as never; const notUsed = () => undefined as never; return { - environmentUsesRelayTransport: () => false, + environmentRequiresRpcAssetTransport: () => false, getEnvironmentHttpBaseUrl: () => "http://localhost:3000", getSavedEnvironmentRecord: () => null, getSavedEnvironmentRuntimeState: () => null, diff --git a/apps/web/src/components/chat/FirstRunSetupCard.tsx b/apps/web/src/components/chat/FirstRunSetupCard.tsx index af7ee575d..5edc6f814 100644 --- a/apps/web/src/components/chat/FirstRunSetupCard.tsx +++ b/apps/web/src/components/chat/FirstRunSetupCard.tsx @@ -178,7 +178,11 @@ export function FirstRunSetupCard({ const projectDescription: ReactNode = projectRow.state === "ready" && projectCwd && projectEnvironmentId ? ( - + {projectRow.description} ) : ( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index afa6da078..ec190a2bb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -47,7 +47,7 @@ import { import { DEFAULT_SCROLL_END_TOLERANCE_PX, isScrollMetricsAtEnd } from "../ChatView.logic"; import { type ChatAttachment, type TurnDiffSummary } from "../../types"; import { chatAttachmentPreviewQueryOptions } from "../../lib/attachmentPreviewQuery"; -import { environmentUsesRelayTransport } from "../../environments/runtime"; +import { environmentRequiresRpcAssetTransport } from "../../environments/runtime"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import ChatMarkdown from "../ChatMarkdown"; import { @@ -1837,11 +1837,12 @@ const EMPTY_IMAGE_PREVIEW_ITEMS: ReadonlyArray = []; /** * Message attachments carry HTTP preview URLs against the environment's base - * URL. Relay-paired environments (phonelink) can't reach that route — the - * relay tunnels only the WebSocket — so swap those previews for data URLs - * fetched over the RPC channel. Locally-echoed blob/data previews (composer - * handoff) pass through untouched. Only chat attachments belong here: work - * entry images may carry foreign http URLs that are not stored attachments. + * URL. A saved environment's route is cross-origin and authenticated over its + * WebSocket, which the browser cannot attach to an `` request, so swap + * those previews for data URLs fetched over the RPC channel. Locally-echoed + * blob/data previews (composer handoff) pass through untouched. Only chat + * attachments belong here: work entry images may carry foreign http URLs that + * are not stored attachments. */ function useResolvedAttachmentPreviews( images: ReadonlyArray, @@ -1849,7 +1850,7 @@ function useResolvedAttachmentPreviews( const ctx = use(TimelineRowCtx); const environmentId = ctx.activeThreadEnvironmentId; const rpcImages = - images.length > 0 && environmentUsesRelayTransport(environmentId) + images.length > 0 && environmentRequiresRpcAssetTransport(environmentId) ? images.filter((image) => image.previewUrl && /^https?:/i.test(image.previewUrl)) : EMPTY_IMAGE_PREVIEW_ITEMS; const previewQueries = useQueries({ diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 76f923446..891287f5c 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -60,7 +60,7 @@ vi.mock("../../environments/runtime", () => { }; return { - environmentUsesRelayTransport: () => false, + environmentRequiresRpcAssetTransport: () => false, getEnvironmentHttpBaseUrl: () => "http://localhost:3000", getSavedEnvironmentRecord: () => null, getSavedEnvironmentRuntimeState: () => null, diff --git a/apps/web/src/components/settings/AgentInstructionsSettings.tsx b/apps/web/src/components/settings/AgentInstructionsSettings.tsx index 554962527..0e112411f 100644 --- a/apps/web/src/components/settings/AgentInstructionsSettings.tsx +++ b/apps/web/src/components/settings/AgentInstructionsSettings.tsx @@ -462,7 +462,13 @@ export function AgentInstructionsSettingsPanel() { {selectedProjectEnvironmentId ? ( - + project.value === cwd)?.label ?? "" + } + /> ) : null} {projectOptions.find((project) => project.value === cwd)?.label ?? @@ -481,6 +487,7 @@ export function AgentInstructionsSettingsPanel() { ) : null} {project.label} diff --git a/apps/web/src/components/settings/ExtensionsSettings.tsx b/apps/web/src/components/settings/ExtensionsSettings.tsx index d2c3dfd02..4b6ea0204 100644 --- a/apps/web/src/components/settings/ExtensionsSettings.tsx +++ b/apps/web/src/components/settings/ExtensionsSettings.tsx @@ -4583,7 +4583,13 @@ export function ExtensionsSettingsPanel() { {selectedEnvironmentId ? ( - + project.value === cwd)?.label ?? "" + } + /> ) : null} {projectOptions.find((project) => project.value === cwd)?.label ?? @@ -4602,6 +4608,7 @@ export function ExtensionsSettingsPanel() { ) : null} {project.label} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index d77228a5e..b5227a187 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -260,7 +260,7 @@ vi.mock("../../environments/runtime", () => { }; return { - environmentUsesRelayTransport: () => false, + environmentRequiresRpcAssetTransport: () => false, getEnvironmentHttpBaseUrl: () => "http://localhost:3000", getSavedEnvironmentRecord: () => null, getSavedEnvironmentRuntimeState: () => null, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 325b6640c..4c572b44e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2218,7 +2218,13 @@ export function ArchivedThreadsPanel() { } + icon={ + + } > {projectThreads.map((thread) => ( (state: import("../../store").AppState) => - selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? - null, - [thread.environmentId, thread.projectId], + useShallow( + useMemo( + () => (state: import("../../store").AppState) => { + const project = selectProjectByRef( + state, + scopeProjectRef(thread.environmentId, thread.projectId), + ); + return project ? { cwd: project.cwd, name: project.name } : null; + }, + [thread.environmentId, thread.projectId], + ), ), ); } +/** + * Which machine a thread is running on, when that is a question worth asking. + * + * The cloud marks anything not on this device. The name beside it only appears + * once the list actually spans machines -- with one remote paired, every cloud + * in the list means the same thing and spelling it out on every row is noise. + */ +function ThreadEnvironmentBadge(props: { thread: SidebarThreadSummary; showLabel: boolean }) { + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const runtimeLabel = useSavedEnvironmentRuntimeStore( + (state) => state.byId[props.thread.environmentId]?.descriptor?.label ?? null, + ); + const savedLabel = useSavedEnvironmentRegistryStore( + (state) => state.byId[props.thread.environmentId]?.label ?? null, + ); + if (primaryEnvironmentId === null || props.thread.environmentId === primaryEnvironmentId) { + return null; + } + + const label = runtimeLabel ?? savedLabel ?? "Remote"; + return ( + + + } + > + + + {label} + + {props.showLabel ? ( + + {label} + + ) : null} + + ); +} + function formatDiffCount(count: number): string { return count >= 1_000 ? `${Math.round(count / 100) / 10}k` : `${count}`; } @@ -159,6 +206,8 @@ export interface InboxThreadRowProps { status: ThreadStatusPill | null; /** Null while the list is scoped to one project: the label is implied. */ projectLabel: string | null; + /** True only while the list spans machines; see {@link ThreadEnvironmentBadge}. */ + showEnvironmentLabel: boolean; isActive: boolean; jumpLabel: string | null; /** False while the thread is moving or blocked: live work can't be waved away. */ @@ -205,6 +254,7 @@ export const InboxThreadRow = memo(function InboxThreadRow(props: InboxThreadRow thread, status, projectLabel, + showEnvironmentLabel, isActive, jumpLabel, canMarkDone, @@ -231,21 +281,9 @@ export const InboxThreadRow = memo(function InboxThreadRow(props: InboxThreadRow (state) => selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, ); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = useSavedEnvironmentRuntimeStore( - (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, - ); - const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( - (s) => s.byId[thread.environmentId]?.label ?? null, - ); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") - : null; - const threadProjectCwd = useThreadProjectCwd(thread); + const threadProject = useThreadProject(thread); const gitCwd = resolveThreadWorkingCwd({ - projectCwd: threadProjectCwd, + projectCwd: threadProject?.cwd ?? null, worktreePath: thread.worktreePath, effectiveCwd: thread.effectiveCwd, }); @@ -436,10 +474,11 @@ export const InboxThreadRow = memo(function InboxThreadRow(props: InboxThreadRow ) : null} - {threadProjectCwd ? ( + {threadProject ? ( ) : null} @@ -556,21 +595,7 @@ export const InboxThreadRow = memo(function InboxThreadRow(props: InboxThreadRow /> ) : null} - {isRemoteThread ? ( - - - } - > - - - {threadEnvironmentLabel} - - ) : null} + {prStatus ? ( @@ -797,6 +826,7 @@ export const InboxDoneRow = memo(function InboxDoneRow(props: InboxDoneRowProps) {thread.title} + {terminalStatus ? ( ; scopedProjectKey: string | null; onScopeChange: (projectKey: string | null) => void; + /** Empty or single-entry while only one machine is known: no filter is offered. */ + environmentOptions: readonly EnvironmentScopeOption[]; + scopedEnvironmentId: string | null; + onEnvironmentScopeChange: (environmentId: string | null) => void; onAddProject: () => void; onNewThread: () => void; newThreadShortcutLabel: string | null; @@ -103,6 +121,9 @@ export const ProjectScopeMenu = memo(function ProjectScopeMenu(props: ProjectSco projectByKey, scopedProjectKey, onScopeChange, + environmentOptions, + scopedEnvironmentId, + onEnvironmentScopeChange, onAddProject, onNewThread, newThreadShortcutLabel, @@ -466,6 +487,14 @@ export const ProjectScopeMenu = memo(function ProjectScopeMenu(props: ProjectSco const scopedProject = scopedProjectKey === null ? null : (projectByKey.get(scopedProjectKey) ?? null); + // One machine is not a choice: the section only appears once there is + // somewhere else the work could be. + const showEnvironmentScope = environmentOptions.length > 1; + const scopedEnvironmentLabel = + showEnvironmentScope && scopedEnvironmentId !== null + ? (environmentOptions.find((option) => option.environmentId === scopedEnvironmentId)?.label ?? + null) + : null; return ( <> @@ -489,6 +518,7 @@ export const ProjectScopeMenu = memo(function ProjectScopeMenu(props: ProjectSco ) : ( @@ -496,6 +526,7 @@ export const ProjectScopeMenu = memo(function ProjectScopeMenu(props: ProjectSco )} {scopedProject?.displayName ?? "All projects"} + {scopedEnvironmentLabel ? ` · ${scopedEnvironmentLabel}` : ""} @@ -536,6 +567,7 @@ export const ProjectScopeMenu = memo(function ProjectScopeMenu(props: ProjectSco ) : ( @@ -567,6 +599,45 @@ export const ProjectScopeMenu = memo(function ProjectScopeMenu(props: ProjectSco ); })} + {showEnvironmentScope ? ( + <> + + Machine + { + onEnvironmentScopeChange(null); + }} + > + + All machines + + {environmentOptions.map((option) => ( + { + onEnvironmentScopeChange(option.environmentId); + }} + > + {option.isPrimary ? ( + + ) : ( + + )} + {option.label} + + ))} + + ) : null} diff --git a/apps/web/src/components/sidebar/SidebarDrafts.browser.tsx b/apps/web/src/components/sidebar/SidebarDrafts.browser.tsx index 08710e5fa..521ad307a 100644 --- a/apps/web/src/components/sidebar/SidebarDrafts.browser.tsx +++ b/apps/web/src/components/sidebar/SidebarDrafts.browser.tsx @@ -79,6 +79,7 @@ function renderDraftBlock(input: { store, projectInfoByScopedRef: PROJECT_INFO, scopedProjectKey: null, + scopedEnvironmentId: null, routeDraftId, frozenOpenDraftRow, }), @@ -92,6 +93,7 @@ function renderDraftBlock(input: { ) : null} @@ -301,6 +313,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { projectInfoByScopedRef: SidebarDraftProjectInfoByScopedRef; scopedProjectKey: string | null; + scopedEnvironmentId: string | null; routeDraftId: string | null; /** From {@link useFrozenOpenDraftRow}, owned by the sidebar. */ frozenOpenDraftRow: SidebarDraftRowData | null; @@ -314,6 +327,7 @@ export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { frozenOpenDraftRow, projectInfoByScopedRef, routeDraftId, + scopedEnvironmentId, scopedProjectKey: scopeKey, } = props; const drafts = useMemo(() => { @@ -327,8 +341,10 @@ export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { } if ( !draftSessionMatchesScope({ + session, project: projectInfoByScopedRef.get(draftProjectRefKey(session)), scopedProjectKey: scopeKey, + scopedEnvironmentId, }) ) { continue; @@ -356,6 +372,7 @@ export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { frozenOpenDraftRow, projectInfoByScopedRef, routeDraftId, + scopedEnvironmentId, scopeKey, ]); const handleDiscardOpenChange = useCallback((open: boolean) => { diff --git a/apps/web/src/components/sidebar/ThreadHoverCard.tsx b/apps/web/src/components/sidebar/ThreadHoverCard.tsx index 6423652dc..e96ca55e2 100644 --- a/apps/web/src/components/sidebar/ThreadHoverCard.tsx +++ b/apps/web/src/components/sidebar/ThreadHoverCard.tsx @@ -148,7 +148,13 @@ function ThreadHoverCardContent({ thread, status }: ThreadHoverCardPayload) { {project ? ( } + icon={ + + } > {project.name} diff --git a/apps/web/src/components/source-control/SourceControlPanel.browser.tsx b/apps/web/src/components/source-control/SourceControlPanel.browser.tsx index 6b6b2e542..107654cfb 100644 --- a/apps/web/src/components/source-control/SourceControlPanel.browser.tsx +++ b/apps/web/src/components/source-control/SourceControlPanel.browser.tsx @@ -92,7 +92,7 @@ vi.mock("~/environments/runtime", () => { connectDesktopSshEnvironment: vi.fn(async () => undefined), disconnectSavedEnvironment: vi.fn(async () => undefined), ensureEnvironmentConnectionBootstrapped: vi.fn(async () => undefined), - environmentUsesRelayTransport: vi.fn(() => false), + environmentRequiresRpcAssetTransport: vi.fn(() => false), getEnvironmentHttpBaseUrl: vi.fn(() => null), getPrimaryEnvironmentConnection: vi.fn(() => connection), getSavedEnvironmentRecord: vi.fn(() => null), diff --git a/apps/web/src/environments/runtime/catalog.ts b/apps/web/src/environments/runtime/catalog.ts index 064962c80..ba1ac5b1b 100644 --- a/apps/web/src/environments/runtime/catalog.ts +++ b/apps/web/src/environments/runtime/catalog.ts @@ -203,13 +203,19 @@ export function getSavedEnvironmentRecord( } /** - * Relay-paired environments (phonelink) tunnel only the WebSocket; their - * `httpBaseUrl` points at the relay origin, which serves no server routes. - * Callers that would fetch server HTTP resources (attachment previews, - * favicons, …) must use an RPC transport instead when this returns true. + * Whether server-held bytes (attachment previews, favicons, …) must travel + * over the WebSocket RPC instead of a plain `` / `fetch`. + * + * True for every saved environment, relay-paired or direct. A saved + * environment authenticates over its WebSocket, and the browser cannot attach + * that credential to a cross-origin request: no cookie is scoped to the other + * machine's origin and no bearer header rides along, so the request comes back + * 401. Relay-paired environments (phonelink) can't even reach the route — the + * relay tunnels only the WebSocket. Only the primary environment, which is + * same-origin, keeps the direct HTTP path. */ -export function environmentUsesRelayTransport(environmentId: EnvironmentId): boolean { - return getSavedEnvironmentRecord(environmentId)?.relay != null; +export function environmentRequiresRpcAssetTransport(environmentId: EnvironmentId): boolean { + return getSavedEnvironmentRecord(environmentId) != null; } export function getEnvironmentHttpBaseUrl(environmentId: EnvironmentId): string | null { diff --git a/apps/web/src/environments/runtime/index.ts b/apps/web/src/environments/runtime/index.ts index 9ff39aeb0..460ac2f8d 100644 --- a/apps/web/src/environments/runtime/index.ts +++ b/apps/web/src/environments/runtime/index.ts @@ -1,5 +1,5 @@ export { - environmentUsesRelayTransport, + environmentRequiresRpcAssetTransport, getEnvironmentHttpBaseUrl, getSavedEnvironmentRecord, getSavedEnvironmentRuntimeState, diff --git a/apps/web/src/hooks/useSidebarProjectSnapshots.ts b/apps/web/src/hooks/useSidebarProjectSnapshots.ts new file mode 100644 index 000000000..e44747692 --- /dev/null +++ b/apps/web/src/hooks/useSidebarProjectSnapshots.ts @@ -0,0 +1,55 @@ +import { useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; + +import { usePrimaryEnvironmentId } from "../environments/primary"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; +import { selectProjectGroupingSettings } from "../logicalProject"; +import { + buildSidebarProjectSnapshots, + type SidebarProjectSnapshot, +} from "../sidebarProjectGrouping"; +import { selectProjectsAcrossEnvironments, useStore } from "../store"; +import { useSettings } from "./useSettings"; + +/** + * The logical project list: one row per repository, however many machines and + * checkouts it lives on. + * + * The sidebar and the composer's project picker have to agree about what a + * project is — a repo present on two machines is one entry in both, or the same + * repo shows up twice in one place and once in the other. Wiring the grouping + * (store projects, grouping settings, which environment is primary, what each + * environment is called) belongs in one place, so this is it. + */ +export function useSidebarProjectSnapshots(): SidebarProjectSnapshot[] { + const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((store) => store.byId); + const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((store) => store.byId); + + return useMemo( + () => + buildSidebarProjectSnapshots({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + // The label a machine reports while connected wins over the one it was + // saved under, so a renamed machine reads correctly without re-pairing. + resolveEnvironmentLabel: (environmentId) => + savedEnvironmentRuntimeById[environmentId]?.descriptor?.label ?? + savedEnvironmentRegistry[environmentId]?.label ?? + null, + }), + [ + projects, + projectGroupingSettings, + primaryEnvironmentId, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ], + ); +} diff --git a/apps/web/src/lib/attachmentPreviewQuery.ts b/apps/web/src/lib/attachmentPreviewQuery.ts index eb989d917..c26b45a23 100644 --- a/apps/web/src/lib/attachmentPreviewQuery.ts +++ b/apps/web/src/lib/attachmentPreviewQuery.ts @@ -1,7 +1,10 @@ import type { EnvironmentId } from "@threadlines/contracts"; import { queryOptions } from "@tanstack/react-query"; import { ensureEnvironmentApi } from "~/environmentApi"; -import { environmentUsesRelayTransport, resolveEnvironmentHttpUrl } from "~/environments/runtime"; +import { + environmentRequiresRpcAssetTransport, + resolveEnvironmentHttpUrl, +} from "~/environments/runtime"; export const attachmentPreviewQueryKeys = { preview: (environmentId: EnvironmentId, attachmentId: string) => @@ -10,9 +13,9 @@ export const attachmentPreviewQueryKeys = { /** * Fetches stored attachment bytes over the environment's WebSocket RPC and - * yields a data URL. Relay-paired environments (phonelink) cannot reach the - * server's `/attachments` HTTP route — the relay only carries the WebSocket — - * so this is their only preview transport. + * yields a data URL. Saved environments authenticate over that WebSocket, and + * the browser cannot attach that credential to a cross-origin `/attachments` + * request, so this is their only preview transport. */ export function chatAttachmentPreviewQueryOptions(input: { environmentId: EnvironmentId; @@ -36,15 +39,15 @@ export function attachmentPreviewRoutePath(attachmentId: string): string { } /** - * Loads a stored attachment's raw bytes: over HTTP against the environment's - * `/attachments` route, or over the WebSocket RPC for relay-paired - * environments where that route is unreachable. + * Loads a stored attachment's raw bytes: over HTTP against the primary + * environment's `/attachments` route, or over the WebSocket RPC for saved + * environments, whose route the browser cannot authenticate against. */ export async function loadChatAttachmentBlob(input: { environmentId: EnvironmentId; attachmentId: string; }): Promise { - if (environmentUsesRelayTransport(input.environmentId)) { + if (environmentRequiresRpcAssetTransport(input.environmentId)) { const api = ensureEnvironmentApi(input.environmentId); const attachment = await api.attachments.read({ attachmentId: input.attachmentId }); const binary = atob(attachment.base64); diff --git a/apps/web/src/lib/projectReactQuery.ts b/apps/web/src/lib/projectReactQuery.ts index 67eccd5fc..d1282a8e8 100644 --- a/apps/web/src/lib/projectReactQuery.ts +++ b/apps/web/src/lib/projectReactQuery.ts @@ -116,10 +116,11 @@ export function projectReadFileQueryOptions(input: { const PROJECT_FAVICON_STALE_TIME = 60 * 60_000; /** - * Fetches the project favicon over the environment's WebSocket RPC and - * yields a data URL, or null when the project has no icon. Used instead of - * the `/api/project-favicon` HTTP route for relay-paired environments - * (phonelink), where the relay carries only the WebSocket. + * Fetches the project favicon over the environment's WebSocket RPC and yields + * a data URL, or null when the project has no icon. The only favicon + * transport: the `/api/project-favicon` HTTP route is unauthenticated + * cross-origin for saved environments, and answers "no icon" with a fallback + * SVG that the caller cannot tell apart from a real one. */ export function projectFaviconQueryOptions(input: { environmentId: EnvironmentId; diff --git a/apps/web/src/sidebarProjectGrouping.test.ts b/apps/web/src/sidebarProjectGrouping.test.ts new file mode 100644 index 000000000..3f9de54dc --- /dev/null +++ b/apps/web/src/sidebarProjectGrouping.test.ts @@ -0,0 +1,110 @@ +import { EnvironmentId, ProjectId, ProviderInstanceId } from "@threadlines/contracts"; +import { scopeProjectRef } from "@threadlines/client-runtime"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildSidebarProjectSnapshots, + orderSnapshotsByProjectRefs, +} from "./sidebarProjectGrouping"; +import type { Project } from "./types"; + +const primaryEnvId = EnvironmentId.make("env-primary"); +const remoteEnvId = EnvironmentId.make("env-remote"); + +// One repository, cloned to a different path on each machine: this is the +// identity the grouping collapses on. +const SHARED_REPO = { + canonicalKey: "github.com/example/shared", + displayName: "shared", + name: "shared", +}; + +const GROUPING_SETTINGS = { + sidebarProjectGroupingMode: "repository" as const, + sidebarProjectGroupingOverrides: {}, +}; + +function makeProject( + overrides: Partial & Pick, +): Project { + return { + kind: "workspace", + cwd: `/tmp/${overrides.name}`, + defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [], + ...overrides, + }; +} + +describe("orderSnapshotsByProjectRefs", () => { + it("collapses a repo present on two machines onto its first appearance in the given order", () => { + // Same repo, one checkout per machine, plus an unrelated project between + // them in the activity order the picker was handed. + const sharedOnRemote = makeProject({ + id: ProjectId.make("shared-remote"), + environmentId: remoteEnvId, + name: "shared", + cwd: "/remote/shared", + repositoryIdentity: SHARED_REPO, + }); + const otherLocal = makeProject({ + id: ProjectId.make("other-local"), + environmentId: primaryEnvId, + name: "other", + cwd: "/local/other", + }); + const sharedOnPrimary = makeProject({ + id: ProjectId.make("shared-primary"), + environmentId: primaryEnvId, + name: "shared", + cwd: "/local/shared", + repositoryIdentity: SHARED_REPO, + }); + const projects = [sharedOnRemote, otherLocal, sharedOnPrimary]; + + const ordered = orderSnapshotsByProjectRefs({ + snapshots: buildSidebarProjectSnapshots({ + projects, + settings: GROUPING_SETTINGS, + primaryEnvironmentId: primaryEnvId, + resolveEnvironmentLabel: () => "MacBook Pro", + }), + orderedProjectRefs: projects.map((project) => + scopeProjectRef(project.environmentId, project.id), + ), + }); + + // Two rows, not three: the shared repo keeps the earlier of its two slots, + // and nothing else is re-sorted around it. + expect(ordered.map((snapshot) => snapshot.displayName)).toEqual(["shared", "other"]); + expect(ordered[0]?.memberProjectRefs).toHaveLength(2); + // The representative is the checkout on this machine, so a click lands + // locally even though the remote one was seen first. + expect(ordered[0]?.environmentId).toBe(primaryEnvId); + expect(ordered[0]?.environmentPresence).toBe("mixed"); + }); + + it("skips refs with no snapshot instead of emitting a hole", () => { + const local = makeProject({ + id: ProjectId.make("local"), + environmentId: primaryEnvId, + name: "local", + }); + const ordered = orderSnapshotsByProjectRefs({ + snapshots: buildSidebarProjectSnapshots({ + projects: [local], + settings: GROUPING_SETTINGS, + primaryEnvironmentId: primaryEnvId, + resolveEnvironmentLabel: () => null, + }), + orderedProjectRefs: [ + scopeProjectRef(remoteEnvId, ProjectId.make("gone")), + scopeProjectRef(primaryEnvId, local.id), + ], + }); + + expect(ordered.map((snapshot) => snapshot.id)).toEqual([local.id]); + }); +}); diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 150d52bd3..c5eece8ff 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -1,4 +1,4 @@ -import { scopeProjectRef } from "@threadlines/client-runtime"; +import { scopedProjectKey, scopeProjectRef } from "@threadlines/client-runtime"; import type { EnvironmentId, ScopedProjectRef } from "@threadlines/contracts"; import { deriveLogicalProjectKeyFromSettings, @@ -39,6 +39,39 @@ export function buildPhysicalToLogicalProjectKeyMap(input: { return mapping; } +/** + * Puts logical projects in the order a list of physical projects is already in, + * keeping each one at the position of its first member. + * + * Project pickers order their physical list by activity (or by the user's + * manual order). Grouping several checkouts into one row must not re-sort that + * list: the same repo on two machines collapses onto whichever of them was + * touched most recently, and everything else keeps its place. + */ +export function orderSnapshotsByProjectRefs(input: { + snapshots: ReadonlyArray; + orderedProjectRefs: ReadonlyArray; +}): SidebarProjectSnapshot[] { + const snapshotByScopedRef = new Map(); + for (const snapshot of input.snapshots) { + for (const memberRef of snapshot.memberProjectRefs) { + snapshotByScopedRef.set(scopedProjectKey(memberRef), snapshot); + } + } + + const ordered: SidebarProjectSnapshot[] = []; + const seen = new Set(); + for (const projectRef of input.orderedProjectRefs) { + const snapshot = snapshotByScopedRef.get(scopedProjectKey(projectRef)); + if (!snapshot || seen.has(snapshot.projectKey)) { + continue; + } + seen.add(snapshot.projectKey); + ordered.push(snapshot); + } + return ordered; +} + export function buildSidebarProjectSnapshots(input: { projects: ReadonlyArray; settings: ProjectGroupingSettings; diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index bebe58c6a..a6a07c76e 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -9,6 +9,7 @@ import { type PersistedUiState, persistState, readLegacyInboxState, + readPersistedState, reorderProjects, resolveSeenOverlay, setDefaultAdvertisedEndpointKey, @@ -28,6 +29,7 @@ function makeUiState(overrides: Partial = {}): UiState { threadChangedFilesExpandedById: {}, doneThreadOverlays: {}, inboxProjectScopeKey: null, + inboxEnvironmentScopeId: null, defaultAdvertisedEndpointKey: null, ...overrides, }; @@ -524,6 +526,25 @@ describe("uiStateStore persistence round-trip", () => { expect(remaining.threadLastVisitedAtById).toEqual({}); }); + it("carries the inbox machine filter across a restart, and drops a junk value", () => { + const state = makeUiState({ + inboxProjectScopeKey: "github.com/example/repo", + inboxEnvironmentScopeId: "env-macbook", + }); + persistState(state); + + expect(readPersistedState().inboxEnvironmentScopeId).toBe("env-macbook"); + expect(readPersistedState().inboxProjectScopeKey).toBe("github.com/example/repo"); + + // An empty or non-string value is "no filter", never a scope nothing can + // match -- that would hide the whole inbox behind a filter with no name. + window.localStorage.setItem( + PERSISTED_STATE_KEY, + JSON.stringify({ inboxEnvironmentScopeId: "" } satisfies PersistedUiState), + ); + expect(readPersistedState().inboxEnvironmentScopeId).toBe(null); + }); + it("preserves all-collapsed project state across restart", () => { // Regression: pre-fix, persistState only wrote `expandedProjectCwds`, so // an empty array on rehydrate was indistinguishable from a fresh install diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 2f63d6904..91820e986 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -31,6 +31,7 @@ export interface PersistedUiState { /** Legacy, see `doneThreadOverrides`. */ threadLastVisitedAtById?: Record; inboxProjectScopeKey?: string | null; + inboxEnvironmentScopeId?: string | null; } export interface UiProjectState { @@ -79,6 +80,8 @@ export interface UiInboxState { doneThreadOverlays: Record; /** Which project chip is selected; null is All. */ inboxProjectScopeKey: string | null; + /** Which machine the list is narrowed to; null is All machines. */ + inboxEnvironmentScopeId: string | null; } export interface UiEndpointState { @@ -111,6 +114,7 @@ const initialState: UiState = { threadChangedFilesExpandedById: {}, doneThreadOverlays: {}, inboxProjectScopeKey: null, + inboxEnvironmentScopeId: null, defaultAdvertisedEndpointKey: null, }; @@ -127,7 +131,7 @@ const currentProjectCwdsByLogicalKey = new Map(); const currentLogicalKeyByPhysicalKey = new Map(); let legacyKeysCleanedUp = false; -function readPersistedState(): UiState { +export function readPersistedState(): UiState { if (typeof window === "undefined") { return initialState; } @@ -160,6 +164,11 @@ function readPersistedState(): UiState { typeof parsed.inboxProjectScopeKey === "string" && parsed.inboxProjectScopeKey.length > 0 ? parsed.inboxProjectScopeKey : null, + inboxEnvironmentScopeId: + typeof parsed.inboxEnvironmentScopeId === "string" && + parsed.inboxEnvironmentScopeId.length > 0 + ? parsed.inboxEnvironmentScopeId + : null, }; } catch { return initialState; @@ -358,6 +367,7 @@ export function persistState(state: UiState): void { defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, inboxProjectScopeKey: state.inboxProjectScopeKey, + inboxEnvironmentScopeId: state.inboxEnvironmentScopeId, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -869,6 +879,7 @@ interface UiStateStore extends UiState { resolveDoneOverlay: (threadKey: string, at: string, outcome: "confirmed" | "failed") => void; resolveSeenOverlay: (threadKey: string, at: string, outcome: "confirmed" | "failed") => void; setInboxProjectScope: (projectKey: string | null) => void; + setInboxEnvironmentScope: (environmentId: string | null) => void; clearThreadUi: (threadKey: string) => void; setThreadChangedFilesExpanded: ( threadId: string, @@ -901,6 +912,12 @@ export const useUiStateStore = create((set) => ({ ? state : { ...state, inboxProjectScopeKey: projectKey }, ), + setInboxEnvironmentScope: (environmentId) => + set((state) => + state.inboxEnvironmentScopeId === environmentId + ? state + : { ...state, inboxEnvironmentScopeId: environmentId }, + ), clearThreadUi: (threadKey) => set((state) => clearThreadUi(state, threadKey)), setThreadChangedFilesExpanded: (threadId, turnId, expanded, defaultExpanded) => set((state) => From 8ff247cd7c1f3c8f879d5b48c97305669ad34346 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:47:13 -0400 Subject: [PATCH 02/12] Keep the user's own terminals out of the Agents panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hand-run command in a thread terminal (vp run dev:desktop) drew a branch in the Agents tab and inflated the closed-panel live count, so a dev server read as an agent. The panel and the indicators that advertise it now count only agent-initiated work: provider-managed runs and detected agent processes. The user's terminals keep their surfaces — the terminal strip and the header's activity popover, which still receives the unfiltered run list. --- .../components/chat/AgentsPanel.browser.tsx | 36 +++++++++++----- .../components/chat/agentsPanel.logic.test.ts | 42 ++++++++++++++----- .../src/components/chat/agentsPanel.logic.ts | 21 ++++++++-- 3 files changed, 76 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/chat/AgentsPanel.browser.tsx b/apps/web/src/components/chat/AgentsPanel.browser.tsx index 373f029e6..3dd09177d 100644 --- a/apps/web/src/components/chat/AgentsPanel.browser.tsx +++ b/apps/web/src/components/chat/AgentsPanel.browser.tsx @@ -54,12 +54,14 @@ function buildHistoryEntry(entry: { return { item: entry.item, resultBody: entry.resultBody ?? null }; } -const TERMINAL_RUN: ThreadBackgroundRunItem = { - id: "terminal:default", - source: "terminal", +// An agent-started run in a managed terminal: the kind the panel lists. +const PROVIDER_RUN: ThreadBackgroundRunItem = { + id: "provider:default", + source: "provider", + providerKind: "command", terminalId: "default", terminalVisible: false, - label: "Terminal 1", + label: "Dev server", command: "vp run dev", detail: "Terminal 1 - C:\\repo", cwd: "C:\\repo", @@ -71,6 +73,16 @@ const TERMINAL_RUN: ThreadBackgroundRunItem = { canStop: true, }; +// The user's own shell: present in the thread, absent from this panel. +const TERMINAL_RUN: ThreadBackgroundRunItem = { + ...PROVIDER_RUN, + id: "terminal:default", + source: "terminal", + providerKind: undefined, + label: "Terminal 1", + command: "vp run dev:desktop", +}; + function renderPanel( props: Partial[0]> = {}, onToggleBackgroundRunTerminal = vi.fn(), @@ -132,7 +144,9 @@ describe("AgentsPanel", () => { statusLabel: "Done", }), ], - backgroundRuns: [TERMINAL_RUN], + // The user's own terminal rides along and must not appear: only the + // provider's run draws a branch. + backgroundRuns: [PROVIDER_RUN, TERMINAL_RUN], }); try { @@ -152,7 +166,7 @@ describe("AgentsPanel", () => { // A run is transcript-less, so it says where it came from instead. const tags = [...document.querySelectorAll("[data-agent-branch-tag='true']")]; - expect(tags.map((tag) => tag.textContent)).toEqual(["terminal"]); + expect(tags.map((tag) => tag.textContent)).toEqual(["codex · provider"]); } finally { await mounted.unmount(); } @@ -182,12 +196,12 @@ describe("AgentsPanel", () => { it("toggles the terminal when a run branch is pressed instead of drilling in", async () => { const onToggleBackgroundRunTerminal = vi.fn(); const mounted = await renderPanel( - { backgroundRuns: [TERMINAL_RUN] }, + { backgroundRuns: [PROVIDER_RUN] }, onToggleBackgroundRunTerminal, ); try { - await page.getByRole("button", { name: "Open Terminal 1 terminal" }).click(); + await page.getByRole("button", { name: "Open Dev server terminal" }).click(); expect(onToggleBackgroundRunTerminal).toHaveBeenCalledWith("default"); // Still the tree: a run never replaces the panel with a transcript. expect(document.querySelector("[data-agents-panel='tree']")).not.toBeNull(); @@ -771,7 +785,7 @@ describe("AgentsPanel", () => { it("marks a spawned agent with the thread provider's glyph but leaves runs their tag", async () => { const mounted = await renderPanel({ subagents: [buildSubagent({ label: "Router sweep" })], - backgroundRuns: [TERMINAL_RUN], + backgroundRuns: [PROVIDER_RUN], providerLabel: "claudeAgent", }); @@ -785,7 +799,9 @@ describe("AgentsPanel", () => { const runRow = rows.find((row) => row.getAttribute("data-agent-branch-kind") === "run"); expect(subagentRow?.querySelector("[data-agent-branch-provider='true'] svg")).not.toBeNull(); expect(runRow?.querySelector("[data-agent-branch-provider='true']")).toBeNull(); - expect(runRow?.querySelector("[data-agent-branch-tag='true']")?.textContent).toBe("terminal"); + expect(runRow?.querySelector("[data-agent-branch-tag='true']")?.textContent).toBe( + "claudeagent · provider", + ); } finally { await mounted.unmount(); } diff --git a/apps/web/src/components/chat/agentsPanel.logic.test.ts b/apps/web/src/components/chat/agentsPanel.logic.test.ts index d29303e6f..28b0d1d5f 100644 --- a/apps/web/src/components/chat/agentsPanel.logic.test.ts +++ b/apps/web/src/components/chat/agentsPanel.logic.test.ts @@ -164,6 +164,29 @@ describe("buildAgentBranches", () => { }); it("marks a run with its provenance and the terminal it toggles", () => { + const branches = buildAgentBranches({ + subagents: [], + backgroundRuns: [ + buildRun({ id: "detected-run" }), + buildRun({ + id: "provider-run", + source: "provider", + providerKind: "command", + terminalId: "terminal-a", + terminalVisible: true, + label: "Dev server task", + }), + ], + providerLabel: "codex", + }); + + expect(branches.map((branch) => branch.tag)).toEqual(["codex · detected", "codex · provider"]); + const providerBranch = branches.find((branch) => branch.key === "run:provider-run"); + expect(providerBranch?.kind === "run" && providerBranch.terminalId).toBe("terminal-a"); + expect(providerBranch?.kind === "run" && providerBranch.terminalVisible).toBe(true); + }); + + it("leaves the user's own terminals out: a hand-run shell is not orchestration", () => { const branches = buildAgentBranches({ subagents: [], backgroundRuns: [ @@ -172,17 +195,13 @@ describe("buildAgentBranches", () => { id: "terminal-run", source: "terminal", terminalId: "terminal-a", - terminalVisible: true, - label: "Terminal 1", + label: "vp run dev:desktop", }), ], providerLabel: "codex", }); - expect(branches.map((branch) => branch.tag)).toEqual(["codex · detected", "terminal"]); - const terminalBranch = branches.find((branch) => branch.key === "run:terminal-run"); - expect(terminalBranch?.kind === "run" && terminalBranch.terminalId).toBe("terminal-a"); - expect(terminalBranch?.kind === "run" && terminalBranch.terminalVisible).toBe(true); + expect(branches.map((branch) => branch.key)).toEqual(["run:detected-run"]); }); it("names a run's served URL as its latest output", () => { @@ -283,9 +302,7 @@ describe("buildAgentsPanelView", () => { it("counts a background run as a run rather than as an agent", () => { const view = viewOf({ subagents: [buildSubagent({ id: "a", agentThreadId: "a", status: "running" })], - backgroundRuns: [ - buildRun({ id: "terminal:default", source: "terminal", terminalId: "default" }), - ], + backgroundRuns: [buildRun({ id: "detected:default" })], }); expect(formatAgentsPanelSummary(view, "claude")).toBe("Claude · 1 running · 1 run"); @@ -508,7 +525,12 @@ describe("summarizeLiveAgents", () => { buildSubagent({ id: "b", status: "waiting" }), buildSubagent({ id: "c", status: "completed" }), ], - backgroundRuns: [buildRun({ id: "run" })], + // The user's own terminal does not make the count: the indicator + // advertises the agents panel, which no longer lists it. + backgroundRuns: [ + buildRun({ id: "run" }), + buildRun({ id: "shell", source: "terminal", terminalId: "default" }), + ], }), ).toEqual({ count: 3, waitingCount: 1 }); }); diff --git a/apps/web/src/components/chat/agentsPanel.logic.ts b/apps/web/src/components/chat/agentsPanel.logic.ts index 834a707d4..80a8719da 100644 --- a/apps/web/src/components/chat/agentsPanel.logic.ts +++ b/apps/web/src/components/chat/agentsPanel.logic.ts @@ -119,6 +119,19 @@ function backgroundRunBranchStatus(run: ThreadBackgroundRunItem): AgentBranchSta return /\b(waiting|blocked|paused)\b/iu.test(run.statusLabel) ? "waiting" : "running"; } +/** + * The runs the panel — and every indicator that advertises it — counts: work an + * agent started, or that detection attributed to one. A terminal the user + * opened themselves is the thread's own shell, not the turn's orchestration; + * the terminal strip and the header's activity popover are its surfaces, and + * counting it here made a hand-run dev server read as an agent. + */ +function agentInitiatedRuns( + runs: ReadonlyArray, +): ReadonlyArray { + return runs.filter((run) => run.source !== "terminal"); +} + /** `codex · detected`. The provider is dropped when it is not known. */ function backgroundRunTag( run: ThreadBackgroundRunItem, @@ -227,7 +240,7 @@ export function buildAgentBranches(input: { branch: subagentBranch(item, input.nowMs) as AgentBranch, startedAtMs: parseTimestamp(item.createdAt), })), - ...input.backgroundRuns.map((run) => ({ + ...agentInitiatedRuns(input.backgroundRuns).map((run) => ({ branch: runBranch(run, input.providerLabel) as AgentBranch, startedAtMs: null, })), @@ -476,7 +489,7 @@ export function summarizeLiveAgents(input: { }): LiveAgentIndicator | null { const statuses = [ ...input.subagents.map((item) => subagentBranchStatus(item.status)), - ...input.backgroundRuns.map(backgroundRunBranchStatus), + ...agentInitiatedRuns(input.backgroundRuns).map(backgroundRunBranchStatus), ].filter(isLiveAgentBranchStatus); if (statuses.length === 0) { return null; @@ -517,7 +530,9 @@ export function hasRunningAgentActivity(input: { }): boolean { return ( input.subagents.some((item) => subagentBranchStatus(item.status) === "running") || - input.backgroundRuns.some((run) => backgroundRunBranchStatus(run) === "running") + agentInitiatedRuns(input.backgroundRuns).some( + (run) => backgroundRunBranchStatus(run) === "running", + ) ); } From f5c947658913fe1f391475e041f11c96c8ea68e0 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:02:59 -0400 Subject: [PATCH 03/12] Wrap the scope menu's Machine section in its menu group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Machine section header rendered outside a MenuGroup, and Base UI's group parts throw without that context — so the whole scope dropdown crashed the moment a second machine was known, which is exactly when the section exists. Covered by a browser test that opens the menu with two machines, since the context check only fires at render. --- .../sidebar/ProjectScopeMenu.browser.tsx | 95 +++++++++++++++++++ .../components/sidebar/ProjectScopeMenu.tsx | 14 ++- 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/sidebar/ProjectScopeMenu.browser.tsx diff --git a/apps/web/src/components/sidebar/ProjectScopeMenu.browser.tsx b/apps/web/src/components/sidebar/ProjectScopeMenu.browser.tsx new file mode 100644 index 000000000..3a808ad4e --- /dev/null +++ b/apps/web/src/components/sidebar/ProjectScopeMenu.browser.tsx @@ -0,0 +1,95 @@ +// The machine section lives inside a Base UI menu, whose group parts enforce +// their context at render time — a wiring mistake there crashes the whole +// dropdown the moment a second machine exists, which no unit test can see. +import "../../index.css"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { EnvironmentId } from "@threadlines/contracts"; +import { page } from "vite-plus/test/browser"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; +import type { ProjectScopeOption } from "../Sidebar.logic"; +import { ProjectScopeMenu, type EnvironmentScopeOption } from "./ProjectScopeMenu"; + +const PRIMARY_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); + +const TWO_MACHINES: EnvironmentScopeOption[] = [ + { environmentId: PRIMARY_ENVIRONMENT_ID, label: "This device", isPrimary: true }, + { environmentId: REMOTE_ENVIRONMENT_ID, label: "Windows Desktop", isPrimary: false }, +]; + +async function renderMenu( + props: Partial[0]> = {}, + onEnvironmentScopeChange = vi.fn(), +) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const mounted = await render( + + ()} + scopedProjectKey={null} + onScopeChange={vi.fn()} + environmentOptions={TWO_MACHINES} + scopedEnvironmentId={null} + onEnvironmentScopeChange={onEnvironmentScopeChange} + onAddProject={vi.fn()} + onNewThread={vi.fn()} + newThreadShortcutLabel={null} + {...props} + /> + , + ); + return { onEnvironmentScopeChange, mounted }; +} + +describe("ProjectScopeMenu", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("opens the machine section with two machines and reports the picked one", async () => { + const { mounted, onEnvironmentScopeChange } = await renderMenu(); + + try { + await page.getByTestId("inbox-scope-trigger").click(); + await expect.element(page.getByText("Machine", { exact: true })).toBeVisible(); + await expect.element(page.getByTestId("inbox-machine-scope-all")).toBeVisible(); + + await page.getByTestId(`inbox-machine-scope-${REMOTE_ENVIRONMENT_ID}`).click(); + expect(onEnvironmentScopeChange).toHaveBeenCalledWith(REMOTE_ENVIRONMENT_ID); + } finally { + await mounted.unmount(); + } + }); + + it("offers no machine section while only one machine is known, and names an active filter on the trigger", async () => { + const { mounted } = await renderMenu({ + environmentOptions: [TWO_MACHINES[0]!], + scopedEnvironmentId: null, + }); + + try { + await page.getByTestId("inbox-scope-trigger").click(); + await expect.element(page.getByTestId("inbox-scope-all")).toBeVisible(); + expect(document.querySelector("[data-testid='inbox-machine-scope-all']")).toBeNull(); + } finally { + await mounted.unmount(); + } + + const { mounted: scopedMounted } = await renderMenu({ + scopedEnvironmentId: REMOTE_ENVIRONMENT_ID, + }); + try { + await expect.element(page.getByTestId("inbox-scope-trigger")).toBeVisible(); + expect(document.querySelector("[data-testid='inbox-scope-trigger']")?.textContent).toContain( + "All projects · Windows Desktop", + ); + } finally { + await scopedMounted.unmount(); + } + }); +}); diff --git a/apps/web/src/components/sidebar/ProjectScopeMenu.tsx b/apps/web/src/components/sidebar/ProjectScopeMenu.tsx index ed856fbe3..11df07e35 100644 --- a/apps/web/src/components/sidebar/ProjectScopeMenu.tsx +++ b/apps/web/src/components/sidebar/ProjectScopeMenu.tsx @@ -44,7 +44,15 @@ import { DialogTitle, } from "../ui/dialog"; import { Input } from "../ui/input"; -import { Menu, MenuGroupLabel, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -600,7 +608,7 @@ export const ProjectScopeMenu = memo(function ProjectScopeMenu(props: ProjectSco ); })} {showEnvironmentScope ? ( - <> + Machine {option.label} ))} - + ) : null} From c7e1d5afd258ed041d25bc24f50e70605b82348c Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:08:29 -0400 Subject: [PATCH 04/12] Compose the machine filter into the project scope options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A machine filter narrows the scope menu's project list to projects with a checkout on that machine, and the project scope lapses to All projects when its project has no presence there — the two scopes intersect instead of crossing into an empty list. - Thread rows keep only the cloud glyph for remote threads; the machine name lives in the tooltip and hover card. Drops the span-aware label plumbing. - The hover card's machine row uses the same glyph pair as every other machine surface: monitor for this device, cloud for a remote machine (was laptop/server). --- apps/web/src/components/Sidebar.tsx | 53 +++++++++++-------- apps/web/src/components/sidebar/InboxRows.tsx | 41 +++++--------- .../components/sidebar/ThreadHoverCard.tsx | 7 ++- 3 files changed, 51 insertions(+), 50 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fdddef3f9..dc41eff57 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -637,11 +637,6 @@ export default function Sidebar() { ], ); - const scopedProjectKeyValue = - inboxProjectScopeKey !== null && sidebarProjectByKey.has(inboxProjectScopeKey) - ? inboxProjectScopeKey - : null; - // Every machine the inbox knows about: this device, plus whatever has been // added under "Add computer". Ordered with this device first, the rest by // name, so the list does not reshuffle as machines connect and drop. @@ -680,6 +675,27 @@ export default function Sidebar() { environmentScopeOptions.some((option) => option.environmentId === inboxEnvironmentScopeId) ? inboxEnvironmentScopeId : null; + + // The two scopes compose as an intersection, so the project scope only + // holds while its project actually lives on the scoped machine — otherwise + // it lapses to All projects rather than pinning the list to an empty cross. + const scopedProjectKeyValue = (() => { + if (inboxProjectScopeKey === null) { + return null; + } + const project = sidebarProjectByKey.get(inboxProjectScopeKey); + if (!project) { + return null; + } + if ( + scopedEnvironmentIdValue !== null && + !project.memberProjects.some((member) => member.environmentId === scopedEnvironmentIdValue) + ) { + return null; + } + return inboxProjectScopeKey; + })(); + const machineScopedEntries = useMemo( () => scopedEnvironmentIdValue === null @@ -751,14 +767,24 @@ export default function Sidebar() { ); } } + // A machine filter narrows the project list with it: offering a project + // that has no checkout on the scoped machine could only produce an empty + // intersection. return buildProjectScopeOptions({ projects: sidebarProjects .filter((project) => project.kind !== "general-chat") + .filter( + (project) => + scopedEnvironmentIdValue === null || + project.memberProjects.some( + (member) => member.environmentId === scopedEnvironmentIdValue, + ), + ) .map((project) => ({ key: project.projectKey, label: project.displayName })), lastActivityMsByKey, needsYouCountByKey, }); - }, [machineScopedEntries, sidebarProjects]); + }, [machineScopedEntries, scopedEnvironmentIdValue, sidebarProjects]); const { liveEntries, doneEntries } = useMemo(() => { const scoped = @@ -782,19 +808,6 @@ export default function Sidebar() { return { liveEntries, doneEntries }; }, [doneThreadOverlays, machineScopedEntries, scopedProjectKeyValue]); - // A machine name on every row is noise while there is only one machine in - // the list -- and while a machine filter is on, every row is that machine. - const showThreadEnvironmentLabels = useMemo(() => { - const environmentIds = new Set(); - for (const entry of [...liveEntries, ...doneEntries]) { - environmentIds.add(entry.thread.environmentId); - if (environmentIds.size > 1) { - return true; - } - } - return false; - }, [doneEntries, liveEntries]); - // Volume is managed by folding, not by flattening rows: quiet threads past // the limit fold away, and anything with a status stays put. const { visible: visibleLiveEntries, hiddenCount: hiddenLiveCount } = useMemo( @@ -1623,7 +1636,6 @@ export default function Sidebar() { thread={entry.thread} status={entry.status} projectLabel={scopedProjectKeyValue === null ? entry.projectLabel : null} - showEnvironmentLabel={showThreadEnvironmentLabels} isActive={routeThreadKey === entry.threadKey} jumpLabel={visibleThreadJumpLabelByKey.get(entry.threadKey) ?? null} canMarkDone={entry.canMarkDone} @@ -1719,7 +1731,6 @@ export default function Sidebar() { projectLabel={ scopedProjectKeyValue === null ? entry.projectLabel : null } - showEnvironmentLabel={showThreadEnvironmentLabels} doneAt={entry.doneAt} isActive={routeThreadKey === entry.threadKey} appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} diff --git a/apps/web/src/components/sidebar/InboxRows.tsx b/apps/web/src/components/sidebar/InboxRows.tsx index 6e36a29f7..d86c8f198 100644 --- a/apps/web/src/components/sidebar/InboxRows.tsx +++ b/apps/web/src/components/sidebar/InboxRows.tsx @@ -143,11 +143,11 @@ function useThreadProject(thread: SidebarThreadSummary): { cwd: string; name: st /** * Which machine a thread is running on, when that is a question worth asking. * - * The cloud marks anything not on this device. The name beside it only appears - * once the list actually spans machines -- with one remote paired, every cloud - * in the list means the same thing and spelling it out on every row is noise. + * The cloud alone marks anything not on this device: the row's meta strip is + * contested space, and the machine's name lives one hover away in the tooltip + * and the hover card, which use the same cloud glyph for the same fact. */ -function ThreadEnvironmentBadge(props: { thread: SidebarThreadSummary; showLabel: boolean }) { +function ThreadEnvironmentBadge(props: { thread: SidebarThreadSummary }) { const primaryEnvironmentId = usePrimaryEnvironmentId(); const runtimeLabel = useSavedEnvironmentRuntimeStore( (state) => state.byId[props.thread.environmentId]?.descriptor?.label ?? null, @@ -161,21 +161,14 @@ function ThreadEnvironmentBadge(props: { thread: SidebarThreadSummary; showLabel const label = runtimeLabel ?? savedLabel ?? "Remote"; return ( - - - } - > - - - {label} - - {props.showLabel ? ( - - {label} - - ) : null} - + + } + > + + + {label} + ); } @@ -206,8 +199,6 @@ export interface InboxThreadRowProps { status: ThreadStatusPill | null; /** Null while the list is scoped to one project: the label is implied. */ projectLabel: string | null; - /** True only while the list spans machines; see {@link ThreadEnvironmentBadge}. */ - showEnvironmentLabel: boolean; isActive: boolean; jumpLabel: string | null; /** False while the thread is moving or blocked: live work can't be waved away. */ @@ -254,7 +245,6 @@ export const InboxThreadRow = memo(function InboxThreadRow(props: InboxThreadRow thread, status, projectLabel, - showEnvironmentLabel, isActive, jumpLabel, canMarkDone, @@ -595,7 +585,7 @@ export const InboxThreadRow = memo(function InboxThreadRow(props: InboxThreadRow /> ) : null} - + {prStatus ? ( - + {terminalStatus ? ( : + isRemote ? : } > {environmentLabel} From 006f031b03cd03c2a522c1600920fecbedb51bdc Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:18:25 -0400 Subject: [PATCH 05/12] Lead with the machine when it is the only scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "All projects · " truncated at sidebar widths to the half that said nothing. A machine-only scope now puts the machine's glyph in the trigger's icon slot and its name as the whole text; "All projects" is reserved for the unfiltered default, and a project scope keeps its " · machine" suffix. --- .../sidebar/ProjectScopeMenu.browser.tsx | 6 ++++-- .../components/sidebar/ProjectScopeMenu.tsx | 20 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/sidebar/ProjectScopeMenu.browser.tsx b/apps/web/src/components/sidebar/ProjectScopeMenu.browser.tsx index 3a808ad4e..a7de3af6c 100644 --- a/apps/web/src/components/sidebar/ProjectScopeMenu.browser.tsx +++ b/apps/web/src/components/sidebar/ProjectScopeMenu.browser.tsx @@ -85,8 +85,10 @@ describe("ProjectScopeMenu", () => { }); try { await expect.element(page.getByTestId("inbox-scope-trigger")).toBeVisible(); - expect(document.querySelector("[data-testid='inbox-scope-trigger']")?.textContent).toContain( - "All projects · Windows Desktop", + // Machine-only scope: the machine IS the label — no "All projects ·" + // prefix to eat the width the name needs. + expect(document.querySelector("[data-testid='inbox-scope-trigger']")?.textContent).toBe( + "Windows Desktop", ); } finally { await scopedMounted.unmount(); diff --git a/apps/web/src/components/sidebar/ProjectScopeMenu.tsx b/apps/web/src/components/sidebar/ProjectScopeMenu.tsx index 11df07e35..dcf0053e4 100644 --- a/apps/web/src/components/sidebar/ProjectScopeMenu.tsx +++ b/apps/web/src/components/sidebar/ProjectScopeMenu.tsx @@ -498,10 +498,9 @@ export const ProjectScopeMenu = memo(function ProjectScopeMenu(props: ProjectSco // One machine is not a choice: the section only appears once there is // somewhere else the work could be. const showEnvironmentScope = environmentOptions.length > 1; - const scopedEnvironmentLabel = + const scopedEnvironment = showEnvironmentScope && scopedEnvironmentId !== null - ? (environmentOptions.find((option) => option.environmentId === scopedEnvironmentId)?.label ?? - null) + ? (environmentOptions.find((option) => option.environmentId === scopedEnvironmentId) ?? null) : null; return ( @@ -529,12 +528,23 @@ export const ProjectScopeMenu = memo(function ProjectScopeMenu(props: ProjectSco name={scopedProject.displayName} className="size-3.5 shrink-0" /> + ) : scopedEnvironment ? ( + // A machine-only scope leads with the machine: its glyph in the + // icon slot and its name as the whole text, because at sidebar + // widths "All projects · " truncated to the half that + // said nothing. + scopedEnvironment.isPrimary ? ( + + ) : ( + + ) ) : ( )} - {scopedProject?.displayName ?? "All projects"} - {scopedEnvironmentLabel ? ` · ${scopedEnvironmentLabel}` : ""} + {scopedProject + ? `${scopedProject.displayName}${scopedEnvironment ? ` · ${scopedEnvironment.label}` : ""}` + : (scopedEnvironment?.label ?? "All projects")} From 2aa5c0d63ffc8e39898e6a31aa26720bf6187e31 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:49:13 -0400 Subject: [PATCH 06/12] Mark project presence with glyphs in the Switch project menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The machine's name truncated to nothing at the menu's row width. A remote-only project now carries the cloud glyph, a repo on both machines carries monitor+cloud, and local-only rows stay unmarked — the same vocabulary as the thread rows and the scope trigger, with the machine names one hover away in the title. --- .../src/components/chat/DraftEmptyState.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/chat/DraftEmptyState.tsx b/apps/web/src/components/chat/DraftEmptyState.tsx index ebbe8375a..e0c1a4503 100644 --- a/apps/web/src/components/chat/DraftEmptyState.tsx +++ b/apps/web/src/components/chat/DraftEmptyState.tsx @@ -1,6 +1,6 @@ import { scopedProjectKey, scopeProjectRef } from "@threadlines/client-runtime"; import type { ScopedProjectRef } from "@threadlines/contracts"; -import { CheckIcon, MessagesSquareIcon } from "lucide-react"; +import { CheckIcon, CloudIcon, MessagesSquareIcon, MonitorIcon } from "lucide-react"; import { useMemo } from "react"; import { usePrimaryEnvironmentId } from "../../environments/primary"; @@ -136,13 +136,12 @@ export function DraftEmptyState({ snapshot.memberProjectRefs.some( (memberRef) => scopedProjectKey(memberRef) === currentProjectKey, ); - // A project that only exists on another machine says so; one - // that lives on both says nothing, because picking it here does - // not pick a machine. - const remoteOnlyLabel = - snapshot.environmentPresence === "remote-only" - ? snapshot.remoteEnvironmentLabels.join(", ") - : ""; + // Where the project lives, in the glyph vocabulary the rest of + // the app speaks: a cloud for another machine, monitor+cloud + // for a repo on both. Glyphs instead of the machine's name — + // the name truncated to nothing at this row width, and hover + // still spells it out. Local-only rows stay unmarked. + const remoteNames = snapshot.remoteEnvironmentLabels.join(", "); return ( {snapshot.displayName} - {remoteOnlyLabel ? ( - - {remoteOnlyLabel} + {snapshot.environmentPresence === "remote-only" ? ( + + + + ) : snapshot.environmentPresence === "mixed" ? ( + + + ) : null} {isCurrentProject ? ( From 0ec1a61e12fee29c262ff7c392f30caa4d577cad Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:59:06 -0400 Subject: [PATCH 07/12] One selection treatment for value-picking menus, richer presence glyphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MenuRadioItem defaults to the fill variant, now with the text half: rows rest muted, the selected row carries a neutral fill and full-strength text, hover is the stronger fill. The check variant's indicator column cost width and misaligned rows (the Run on menu indented its selected row past its siblings). Exported as MENU_PICK_ITEM_* so MenuItem-based pickers (scope menu, Switch project) draw from the same definition; their check glyphs are gone. - Switch project presence glyphs: once any remote machine is connected every row declares where it lives — monitor, cloud, or both, with a mono count when a project spans several remotes. Unmarked rows are ambiguous only when machines can differ, so with no remote connected no glyph renders at all. --- .../src/components/chat/DraftEmptyState.tsx | 67 +++++++++++++------ .../components/sidebar/ProjectScopeMenu.tsx | 11 ++- apps/web/src/components/ui/menu.tsx | 26 +++++-- 3 files changed, 68 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/chat/DraftEmptyState.tsx b/apps/web/src/components/chat/DraftEmptyState.tsx index e0c1a4503..c0963add0 100644 --- a/apps/web/src/components/chat/DraftEmptyState.tsx +++ b/apps/web/src/components/chat/DraftEmptyState.tsx @@ -1,20 +1,24 @@ import { scopedProjectKey, scopeProjectRef } from "@threadlines/client-runtime"; import type { ScopedProjectRef } from "@threadlines/contracts"; -import { CheckIcon, CloudIcon, MessagesSquareIcon, MonitorIcon } from "lucide-react"; +import { CloudIcon, MessagesSquareIcon, MonitorIcon } from "lucide-react"; import { useMemo } from "react"; import { usePrimaryEnvironmentId } from "../../environments/primary"; +import { useSavedEnvironmentRegistryStore } from "../../environments/runtime"; import { useHandleNewThread } from "../../hooks/useHandleNewThread"; import { useSidebarProjectSnapshots } from "../../hooks/useSidebarProjectSnapshots"; import { startNewGeneralChatThread } from "../../lib/chatThreadActions"; import { resolveGeneralChatsProjectRef } from "../../lib/generalChats"; import { orderSnapshotsByProjectRefs } from "../../sidebarProjectGrouping"; import { selectGeneralChatsProjectAcrossEnvironments, useStore } from "../../store"; +import { cn } from "../../lib/utils"; import { ProjectFavicon } from "../ProjectFavicon"; import { RecentThreadsList } from "../RecentThreadsList"; import { riseDelay, ThreadlinesFigure } from "../ThreadlinesFigure"; import { Menu, + MENU_PICK_ITEM_CLASS_NAME, + MENU_PICK_ITEM_SELECTED_CLASS_NAME, MenuGroup, MenuGroupLabel, MenuItem, @@ -66,6 +70,10 @@ export function DraftEmptyState({ // on a remote one is one entry here, exactly as it is in the sidebar. Which // machine a thread runs on is the Run on selector's question, not this one's. const projectSnapshots = useSidebarProjectSnapshots(); + // "Where does this project live?" only exists once a second machine does. + const hasRemoteMachines = useSavedEnvironmentRegistryStore( + (state) => Object.keys(state.byId).length > 0, + ); const menuSnapshots = useMemo( () => orderSnapshotsByProjectRefs({ @@ -113,15 +121,16 @@ export function DraftEmptyState({ <> { void startNewGeneralChatThread(handleNewThread, generalChatsRef); }} > General chat - {isGeneralChat ? ( - - ) : null} @@ -137,14 +146,23 @@ export function DraftEmptyState({ (memberRef) => scopedProjectKey(memberRef) === currentProjectKey, ); // Where the project lives, in the glyph vocabulary the rest of - // the app speaks: a cloud for another machine, monitor+cloud - // for a repo on both. Glyphs instead of the machine's name — - // the name truncated to nothing at this row width, and hover - // still spells it out. Local-only rows stay unmarked. + // the app speaks: monitor for this device, cloud for another + // machine, both for a repo on both, and a count when it spans + // several remotes. Glyphs instead of the machine's name — the + // name truncated to nothing at this row width, and hover still + // spells it out. With no remote machine connected the question + // does not exist, so no row carries a glyph at all. const remoteNames = snapshot.remoteEnvironmentLabels.join(", "); + const remoteCount = snapshot.remoteEnvironmentLabels.length; + const hasLocal = snapshot.environmentPresence !== "remote-only"; + const hasRemote = snapshot.environmentPresence !== "local-only"; return ( { void handleNewThread(projectRef); }} @@ -156,25 +174,30 @@ export function DraftEmptyState({ name={snapshot.displayName} /> {snapshot.displayName} - {snapshot.environmentPresence === "remote-only" ? ( - - - - ) : snapshot.environmentPresence === "mixed" ? ( + {hasRemoteMachines ? ( - - + {hasLocal ? ( + + ) : null} + {hasRemote ? ( + + ) : null} + {remoteCount > 1 ? ( + + {remoteCount} + + ) : null} ) : null} - {isCurrentProject ? ( - - ) : null} ); })} diff --git a/apps/web/src/components/sidebar/ProjectScopeMenu.tsx b/apps/web/src/components/sidebar/ProjectScopeMenu.tsx index dcf0053e4..e1ef272cf 100644 --- a/apps/web/src/components/sidebar/ProjectScopeMenu.tsx +++ b/apps/web/src/components/sidebar/ProjectScopeMenu.tsx @@ -46,6 +46,8 @@ import { import { Input } from "../ui/input"; import { Menu, + MENU_PICK_ITEM_CLASS_NAME, + MENU_PICK_ITEM_SELECTED_CLASS_NAME, MenuGroup, MenuGroupLabel, MenuItem, @@ -85,13 +87,8 @@ function formatProjectMemberActionLabel( return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd; } -const SCOPE_ITEM_CLASS_NAME = "gap-2 data-highlighted:bg-foreground/12"; -/** - * Selection is a resting fill; hover is a stronger one. Both are neutral - * alphas of the foreground, so "which is selected" and "which is under the - * cursor" never read as the same state. - */ -const SCOPE_ITEM_SELECTED = "bg-foreground/6 text-foreground"; +const SCOPE_ITEM_CLASS_NAME = MENU_PICK_ITEM_CLASS_NAME; +const SCOPE_ITEM_SELECTED = MENU_PICK_ITEM_SELECTED_CLASS_NAME; /** One machine the inbox can be narrowed to. */ export interface EnvironmentScopeOption { diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 4b8f82a50..9649be8b2 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -158,12 +158,12 @@ function MenuRadioItem({ // Base UI leaves radio menus open after a pick; our menus render a backdrop, // so staying open would swallow the user's next click outside the menu. closeOnClick = true, - // "check" reserves an indicator column and ticks the selected entry. - // "fill" marks it the way the sidebar's project menu does: selection is a - // resting fill, hover a stronger one, both neutral alphas of the foreground - // so "which is selected" and "which is under the cursor" never read as the - // same state. - variant = "check", + // "fill" is the house selection treatment (see MENU_PICK_ITEM_*): selection + // is a resting fill with full-strength text against muted siblings, hover a + // stronger fill — no indicator column to spend width on or misalign rows. + // "check" reserves an indicator column and ticks the selected entry; keep it + // only where a menu truly cannot use the fill. + variant = "fill", ...props }: MenuPrimitive.RadioItem.Props & { variant?: "check" | "fill" }) { return ( @@ -173,7 +173,7 @@ function MenuRadioItem({ "min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-pointer items-center gap-2 rounded-sm py-1 ps-2 pe-4 text-base text-foreground outline-none data-disabled:pointer-events-none data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", variant === "check" ? "grid grid-cols-[1rem_1fr] data-highlighted:bg-accent data-highlighted:text-accent-foreground" - : "flex data-checked:bg-foreground/6 data-highlighted:bg-foreground/12", + : "flex text-muted-foreground data-checked:bg-foreground/8 data-checked:text-foreground data-highlighted:bg-foreground/12 data-highlighted:text-foreground", className, )} data-slot="menu-radio-item" @@ -205,6 +205,18 @@ function MenuRadioItem({ ); } +/** + * The house selection treatment for value-picking menus built from plain + * MenuItems (MenuRadioItem's "fill" variant applies the same rules itself): + * rows rest muted, the selected row carries a neutral fill and full-strength + * text, and hover is a stronger fill — visibly a different state from + * selection. No check glyph: every picker's trigger already names the current + * value, and an indicator column costs width and row alignment. + */ +export const MENU_PICK_ITEM_CLASS_NAME = + "gap-2 text-muted-foreground data-highlighted:bg-foreground/12 data-highlighted:text-foreground"; +export const MENU_PICK_ITEM_SELECTED_CLASS_NAME = "bg-foreground/8 text-foreground"; + function MenuGroupLabel({ className, inset, From 50610bdbf07848460587868f7dcffb8376e8da29 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:10:11 -0400 Subject: [PATCH 08/12] Extend the picker selection treatment to Select and the model picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop device and checkout pickers are Selects, not menus, so they kept their leading check and its reserved gutter. SelectItem now renders indicator-less with the house treatment (muted rows, neutral fill + full text when selected, stronger fill on hover); hideIndicator is gone along with its call sites — the combobox keeps its own prop for the ref search. The model picker keeps its primary-tinted name as the selection mark per design, drops the inline check, and gains the same resting fill. --- apps/web/src/components/chat/ModelListRow.tsx | 16 +++----- .../settings/AgentInstructionsSettings.tsx | 4 +- .../settings/ExtensionsSettings.tsx | 6 +-- .../components/settings/SettingsPanels.tsx | 34 +++++----------- .../settings/SourceControlSettings.tsx | 2 +- .../components/sidebar/ProjectScopeMenu.tsx | 12 ++---- apps/web/src/components/ui/select.tsx | 40 +++++-------------- 7 files changed, 34 insertions(+), 80 deletions(-) diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index e1de7047e..edd01824a 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -1,6 +1,6 @@ import { type ProviderDriverKind, type ProviderInstanceId } from "@threadlines/contracts"; import { memo } from "react"; -import { CheckIcon, StarIcon } from "lucide-react"; +import { StarIcon } from "lucide-react"; import { getDisplayModelName, getProviderScopedDisplayModelLabel, @@ -61,10 +61,10 @@ export const ModelListRow = memo(function ModelListRow(props: { // Single-line rows keep a compact fixed height; rows with a // description or provider footer grow to two lines. props.model.description || props.showProvider ? "py-1.5" : "h-8 py-0", - // Selection is marked by the inline check + primary-tinted name so - // it stays distinguishable from the grey hover/keyboard highlight - // (--accent and --muted resolve to the same grey in both themes). - "hover:bg-muted data-highlighted:bg-muted data-selected:bg-transparent data-selected:text-foreground [&[data-highlighted][data-selected]]:bg-muted", + // Selection is the house resting fill plus the primary-tinted name — + // the tint alone says "this is the one" and the fill matches every + // other picker; hover/keyboard highlight stays the stronger grey. + "hover:bg-muted data-highlighted:bg-muted data-selected:bg-foreground/8 data-selected:text-foreground [&[data-highlighted][data-selected]]:bg-muted", )} >
@@ -74,12 +74,6 @@ export const ModelListRow = memo(function ModelListRow(props: { data-model-picker-model-name > {modelLabel} - {/* Inline selection check (no left gutter — rows keep their - full width and unselected rows don't carry an empty column). */} -
{/* Favorited rows keep the filled star visible in provider tabs; diff --git a/apps/web/src/components/settings/AgentInstructionsSettings.tsx b/apps/web/src/components/settings/AgentInstructionsSettings.tsx index 0e112411f..f61b5d97d 100644 --- a/apps/web/src/components/settings/AgentInstructionsSettings.tsx +++ b/apps/web/src/components/settings/AgentInstructionsSettings.tsx @@ -481,7 +481,7 @@ export function AgentInstructionsSettingsPanel() { {projectOptions.map((project) => { const projectEnvironmentId = environmentIdByCwd.get(project.value); return ( - + {projectEnvironmentId ? ( { const key = instructionFileKey(file); return ( - + {instructionFileLabel(file)} {dirtyFileKeys.has(key) ? " • Edited" : ""} diff --git a/apps/web/src/components/settings/ExtensionsSettings.tsx b/apps/web/src/components/settings/ExtensionsSettings.tsx index 4b6ea0204..07e6a634c 100644 --- a/apps/web/src/components/settings/ExtensionsSettings.tsx +++ b/apps/web/src/components/settings/ExtensionsSettings.tsx @@ -2236,7 +2236,7 @@ function ExtensionDetailDialog({ {field.enumValues.map((enumValue) => ( - + {enumValue} ))} @@ -3567,7 +3567,7 @@ function ExtensionBrowserDialog({ {sortOptions.map((option) => ( - + {option.label} ))} @@ -4602,7 +4602,7 @@ export function ExtensionsSettingsPanel() { {projectOptions.map((project) => { const projectEnvironmentId = environmentIdByCwd.get(project.value); return ( - + {projectEnvironmentId ? ( - - Stable - - - Nightly - + Stable + Nightly } @@ -580,12 +576,8 @@ function DefaultThreadEnvModeRow() { - - Local - - - New worktree - + Local + New worktree } @@ -695,7 +687,7 @@ export function GeneralSettingsPanel({ surface = "full" }: { surface?: "full" | {THEME_OPTIONS.map((option) => ( - + {option.label} ))} @@ -732,15 +724,9 @@ export function GeneralSettingsPanel({ surface = "full" }: { surface?: "full" | {TIMESTAMP_FORMAT_LABELS[settings.timestampFormat]} - - {TIMESTAMP_FORMAT_LABELS.locale} - - - {TIMESTAMP_FORMAT_LABELS["12-hour"]} - - - {TIMESTAMP_FORMAT_LABELS["24-hour"]} - + {TIMESTAMP_FORMAT_LABELS.locale} + {TIMESTAMP_FORMAT_LABELS["12-hour"]} + {TIMESTAMP_FORMAT_LABELS["24-hour"]} } @@ -2105,7 +2091,7 @@ export function ArchivedThreadsPanel() { {AUTO_ARCHIVE_INACTIVE_THREADS_DAY_OPTIONS.map((days) => ( - + {formatAutoArchiveDaysLabel(days)} ))} @@ -2166,7 +2152,7 @@ export function ArchivedThreadsPanel() { {ARCHIVED_THREAD_DELETE_AGE_OPTIONS.map((days) => ( - + {formatArchivedThreadDeleteAgeLabel(days)} ))} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 8c43dd537..24038df9d 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -513,7 +513,7 @@ function TextGenerationSection() { {WRITING_STYLE_OPTIONS.map((option) => ( - + {option.label} ))} diff --git a/apps/web/src/components/sidebar/ProjectScopeMenu.tsx b/apps/web/src/components/sidebar/ProjectScopeMenu.tsx index e1ef272cf..7cdbc7dd3 100644 --- a/apps/web/src/components/sidebar/ProjectScopeMenu.tsx +++ b/apps/web/src/components/sidebar/ProjectScopeMenu.tsx @@ -784,18 +784,14 @@ export const ProjectScopeMenu = memo(function ProjectScopeMenu(props: ProjectSco - - Use global default - - + Use global default + {PROJECT_GROUPING_MODE_LABELS.repository} - + {PROJECT_GROUPING_MODE_LABELS.repository_path} - - {PROJECT_GROUPING_MODE_LABELS.separate} - + {PROJECT_GROUPING_MODE_LABELS.separate}
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index b68c3eeed..fbc572c31 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -174,45 +174,23 @@ function SelectPopup({ ); } -function SelectItem({ - className, - children, - hideIndicator = false, - ...props -}: SelectPrimitive.Item.Props & { - hideIndicator?: boolean; -}) { +/** + * Selection follows the house picker treatment (see MENU_PICK_ITEM_* in + * ui/menu): rows rest muted, the selected row carries a neutral fill and + * full-strength text, hover is the stronger fill. No indicator column — the + * check cost every row a gutter and the trigger already names the value. + */ +function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) { return ( - {hideIndicator ? null : ( - - - - - - )} - + {children} From 126c932cd6ba2cdcaeb5612f0393ee08102f03f5 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:31:47 -0400 Subject: [PATCH 09/12] Give the picker's selected fill its own token, split from hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selected and hover were two alphas of the same white wash, near indistinguishable at menu size. New --pick-selected token: on dark it darkens the row (recessed, chosen) while hover lightens — different directions, not different strengths; on light it stays a step apart from hover with the full-strength text carrying the rest. All four picker surfaces (menu fill items, MenuRadioItem, SelectItem, model rows) consume the one token. --- apps/web/src/components/chat/ModelListRow.tsx | 2 +- apps/web/src/components/ui/menu.tsx | 4 ++-- apps/web/src/components/ui/select.tsx | 2 +- apps/web/src/index.css | 7 +++++++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index edd01824a..0963a708d 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -64,7 +64,7 @@ export const ModelListRow = memo(function ModelListRow(props: { // Selection is the house resting fill plus the primary-tinted name — // the tint alone says "this is the one" and the fill matches every // other picker; hover/keyboard highlight stays the stronger grey. - "hover:bg-muted data-highlighted:bg-muted data-selected:bg-foreground/8 data-selected:text-foreground [&[data-highlighted][data-selected]]:bg-muted", + "hover:bg-muted data-highlighted:bg-muted data-selected:bg-pick-selected data-selected:text-foreground [&[data-highlighted][data-selected]]:bg-muted", )} >
diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 9649be8b2..75ea15d67 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -173,7 +173,7 @@ function MenuRadioItem({ "min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-pointer items-center gap-2 rounded-sm py-1 ps-2 pe-4 text-base text-foreground outline-none data-disabled:pointer-events-none data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", variant === "check" ? "grid grid-cols-[1rem_1fr] data-highlighted:bg-accent data-highlighted:text-accent-foreground" - : "flex text-muted-foreground data-checked:bg-foreground/8 data-checked:text-foreground data-highlighted:bg-foreground/12 data-highlighted:text-foreground", + : "flex text-muted-foreground data-checked:bg-pick-selected data-checked:text-foreground data-highlighted:bg-foreground/12 data-highlighted:text-foreground", className, )} data-slot="menu-radio-item" @@ -215,7 +215,7 @@ function MenuRadioItem({ */ export const MENU_PICK_ITEM_CLASS_NAME = "gap-2 text-muted-foreground data-highlighted:bg-foreground/12 data-highlighted:text-foreground"; -export const MENU_PICK_ITEM_SELECTED_CLASS_NAME = "bg-foreground/8 text-foreground"; +export const MENU_PICK_ITEM_SELECTED_CLASS_NAME = "bg-pick-selected text-foreground"; function MenuGroupLabel({ className, diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index fbc572c31..0a2ff3f01 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -184,7 +184,7 @@ function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Prop return ( Date: Sat, 15 Aug 2026 02:33:38 -0400 Subject: [PATCH 10/12] Selected picker rows: faint fill plus hairline inset ring The recessed dark fill read too heavy. The chosen row now differs from hover in structure instead of shade: a faint wash with a --border inset hairline against the borderless hover fill. Token values drop to 3%/4%. --- apps/web/src/components/chat/ModelListRow.tsx | 2 +- apps/web/src/components/ui/menu.tsx | 5 +++-- apps/web/src/components/ui/select.tsx | 2 +- apps/web/src/index.css | 11 +++++------ 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 0963a708d..52f3dc3d6 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -64,7 +64,7 @@ export const ModelListRow = memo(function ModelListRow(props: { // Selection is the house resting fill plus the primary-tinted name — // the tint alone says "this is the one" and the fill matches every // other picker; hover/keyboard highlight stays the stronger grey. - "hover:bg-muted data-highlighted:bg-muted data-selected:bg-pick-selected data-selected:text-foreground [&[data-highlighted][data-selected]]:bg-muted", + "hover:bg-muted data-highlighted:bg-muted data-selected:bg-pick-selected data-selected:text-foreground data-selected:inset-ring data-selected:inset-ring-border [&[data-highlighted][data-selected]]:bg-muted", )} >
diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 75ea15d67..ed6e92dea 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -173,7 +173,7 @@ function MenuRadioItem({ "min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-pointer items-center gap-2 rounded-sm py-1 ps-2 pe-4 text-base text-foreground outline-none data-disabled:pointer-events-none data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", variant === "check" ? "grid grid-cols-[1rem_1fr] data-highlighted:bg-accent data-highlighted:text-accent-foreground" - : "flex text-muted-foreground data-checked:bg-pick-selected data-checked:text-foreground data-highlighted:bg-foreground/12 data-highlighted:text-foreground", + : "flex text-muted-foreground data-checked:bg-pick-selected data-checked:text-foreground data-checked:inset-ring data-checked:inset-ring-border data-highlighted:bg-foreground/12 data-highlighted:text-foreground", className, )} data-slot="menu-radio-item" @@ -215,7 +215,8 @@ function MenuRadioItem({ */ export const MENU_PICK_ITEM_CLASS_NAME = "gap-2 text-muted-foreground data-highlighted:bg-foreground/12 data-highlighted:text-foreground"; -export const MENU_PICK_ITEM_SELECTED_CLASS_NAME = "bg-pick-selected text-foreground"; +export const MENU_PICK_ITEM_SELECTED_CLASS_NAME = + "bg-pick-selected text-foreground inset-ring inset-ring-border"; function MenuGroupLabel({ className, diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 0a2ff3f01..4813269b0 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -184,7 +184,7 @@ function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Prop return ( Date: Sat, 15 Aug 2026 02:39:46 -0400 Subject: [PATCH 11/12] Match the branch selector's selected row and make presence tooltips instant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ComboboxItem now carries the house selection treatment (faint pick-selected fill + hairline inset ring + full text), so the ref search's current row matches every other picker; the model row drops its now-redundant copy. The Switch project presence glyphs swap the native title for a zero-delay TooltipWrapper — the glyphs are the only thing naming the machines, so the hover dwell read as unlabelled. --- .../src/components/chat/DraftEmptyState.tsx | 36 +++++++++++-------- apps/web/src/components/chat/ModelListRow.tsx | 8 ++--- apps/web/src/components/ui/combobox.tsx | 2 +- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/chat/DraftEmptyState.tsx b/apps/web/src/components/chat/DraftEmptyState.tsx index c0963add0..23e91ff7c 100644 --- a/apps/web/src/components/chat/DraftEmptyState.tsx +++ b/apps/web/src/components/chat/DraftEmptyState.tsx @@ -15,6 +15,7 @@ import { cn } from "../../lib/utils"; import { ProjectFavicon } from "../ProjectFavicon"; import { RecentThreadsList } from "../RecentThreadsList"; import { riseDelay, ThreadlinesFigure } from "../ThreadlinesFigure"; +import { TooltipWrapper } from "../ui/tooltip"; import { Menu, MENU_PICK_ITEM_CLASS_NAME, @@ -175,28 +176,33 @@ export function DraftEmptyState({ /> {snapshot.displayName} {hasRemoteMachines ? ( - - {hasLocal ? ( - - ) : null} - {hasRemote ? ( - - ) : null} - {remoteCount > 1 ? ( - - {remoteCount} - - ) : null} - + + {hasLocal ? ( + + ) : null} + {hasRemote ? ( + + ) : null} + {remoteCount > 1 ? ( + + {remoteCount} + + ) : null} + + ) : null} ); diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 52f3dc3d6..1be74e488 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -61,10 +61,10 @@ export const ModelListRow = memo(function ModelListRow(props: { // Single-line rows keep a compact fixed height; rows with a // description or provider footer grow to two lines. props.model.description || props.showProvider ? "py-1.5" : "h-8 py-0", - // Selection is the house resting fill plus the primary-tinted name — - // the tint alone says "this is the one" and the fill matches every - // other picker; hover/keyboard highlight stays the stronger grey. - "hover:bg-muted data-highlighted:bg-muted data-selected:bg-pick-selected data-selected:text-foreground data-selected:inset-ring data-selected:inset-ring-border [&[data-highlighted][data-selected]]:bg-muted", + // Selection styling (fill + hairline ring) comes from ComboboxItem; + // the primary-tinted name below is this row's own "this is the one" + // mark. Hover/keyboard highlight stays the stronger grey. + "hover:bg-muted data-highlighted:bg-muted [&[data-highlighted][data-selected]]:bg-muted", )} >
diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 850ed0bde..d9c2ad78b 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -197,7 +197,7 @@ function ComboboxItem({ return ( Date: Sat, 15 Aug 2026 02:49:34 -0400 Subject: [PATCH 12/12] Give the grouping test's repo fixture its required locator Local typecheck replayed a stale cache and let the incomplete RepositoryIdentity fixture through; CI's cold run caught it. --- apps/web/src/sidebarProjectGrouping.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/src/sidebarProjectGrouping.test.ts b/apps/web/src/sidebarProjectGrouping.test.ts index 3f9de54dc..5ace30d12 100644 --- a/apps/web/src/sidebarProjectGrouping.test.ts +++ b/apps/web/src/sidebarProjectGrouping.test.ts @@ -15,6 +15,11 @@ const remoteEnvId = EnvironmentId.make("env-remote"); // identity the grouping collapses on. const SHARED_REPO = { canonicalKey: "github.com/example/shared", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/example/shared.git", + }, displayName: "shared", name: "shared", };