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..dc41eff57 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], @@ -655,11 +637,73 @@ export default function Sidebar() { ], ); - const scopedProjectKeyValue = - inboxProjectScopeKey !== null && sidebarProjectByKey.has(inboxProjectScopeKey) - ? inboxProjectScopeKey + // 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; + // 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 + ? 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 +742,7 @@ export default function Sidebar() { store, projectInfoByScopedRef: draftProjectInfoByScopedRef, scopedProjectKey: scopedProjectKeyValue, + scopedEnvironmentId: scopedEnvironmentIdValue, routeDraftId, frozenOpenDraftRow, }), @@ -706,7 +751,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, @@ -722,20 +767,30 @@ 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, }); - }, [entries, sidebarProjects]); + }, [machineScopedEntries, scopedEnvironmentIdValue, 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 +806,7 @@ export default function Sidebar() { ), ).map(lookup); return { liveEntries, doneEntries }; - }, [doneThreadOverlays, entries, scopedProjectKeyValue]); + }, [doneThreadOverlays, machineScopedEntries, scopedProjectKeyValue]); // 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 +846,7 @@ export default function Sidebar() { useEffect(() => { setRevealedLiveCount(0); setRevealedDoneCount(0); - }, [scopedProjectKeyValue]); + }, [scopedEnvironmentIdValue, scopedProjectKeyValue]); const orderedThreadKeys = useMemo( () => [ @@ -1256,6 +1311,13 @@ export default function Sidebar() { [setInboxProjectScope], ); + const handleEnvironmentScopeChange = useCallback( + (environmentId: string | null) => { + setInboxEnvironmentScope(environmentId); + }, + [setInboxEnvironmentScope], + ); + const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -1527,6 +1589,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 +1602,7 @@ export default function Sidebar() { [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/DraftEmptyState.tsx b/apps/web/src/components/chat/DraftEmptyState.tsx index 7680f5ad0..23e91ff7c 100644 --- a/apps/web/src/components/chat/DraftEmptyState.tsx +++ b/apps/web/src/components/chat/DraftEmptyState.tsx @@ -1,18 +1,25 @@ import { scopedProjectKey, scopeProjectRef } from "@threadlines/client-runtime"; import type { ScopedProjectRef } from "@threadlines/contracts"; -import { CheckIcon, MessagesSquareIcon } 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 { TooltipWrapper } from "../ui/tooltip"; import { Menu, + MENU_PICK_ITEM_CLASS_NAME, + MENU_PICK_ITEM_SELECTED_CLASS_NAME, MenuGroup, MenuGroupLabel, MenuItem, @@ -60,6 +67,24 @@ export function DraftEmptyState({ ) ?? null), [currentProjectKey, orderedProjects], ); + // The menu lists projects, not checkouts: a repo cloned on this machine and + // 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({ + snapshots: projectSnapshots, + orderedProjectRefs: orderedProjects.map((project) => + scopeProjectRef(project.environmentId, project.id), + ), + }), + [orderedProjects, projectSnapshots], + ); return (
@@ -86,6 +111,7 @@ export function DraftEmptyState({ ) : null} {targetName} @@ -96,15 +122,16 @@ export function DraftEmptyState({ <> { void startNewGeneralChatThread(handleNewThread, generalChatsRef); }} > General chat - {isGeneralChat ? ( - - ) : null} @@ -112,22 +139,70 @@ 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, + ); + // Where the project lives, in the glyph vocabulary the rest of + // 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); }} - title={project.cwd} + title={snapshot.cwd} > - - {project.name} - {isCurrentProject ? ( - + + {snapshot.displayName} + {hasRemoteMachines ? ( + // Instant tooltip: the glyphs are the only thing naming + // the machines, so a hover dwell reads as unlabelled. + + + {hasLocal ? ( + + ) : null} + {hasRemote ? ( + + ) : null} + {remoteCount > 1 ? ( + + {remoteCount} + + ) : null} + + ) : 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/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index e1de7047e..1be74e488 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 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", )} >
@@ -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/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/chat/agentsPanel.logic.test.ts b/apps/web/src/components/chat/agentsPanel.logic.test.ts index d1ae8adb7..2dbab2cb4 100644 --- a/apps/web/src/components/chat/agentsPanel.logic.test.ts +++ b/apps/web/src/components/chat/agentsPanel.logic.test.ts @@ -199,6 +199,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: [ @@ -207,17 +230,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", () => { @@ -318,9 +337,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"); @@ -543,7 +560,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 02da4eecb..505eaee03 100644 --- a/apps/web/src/components/chat/agentsPanel.logic.ts +++ b/apps/web/src/components/chat/agentsPanel.logic.ts @@ -124,6 +124,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, @@ -239,7 +252,7 @@ export function buildAgentBranches(input: { branch: subagentBranch(item, input.nowMs, input.subagentRuns) as AgentBranch, startedAtMs: parseTimestamp(item.createdAt), })), - ...input.backgroundRuns.map((run) => ({ + ...agentInitiatedRuns(input.backgroundRuns).map((run) => ({ branch: runBranch(run, input.providerLabel) as AgentBranch, startedAtMs: null, })), @@ -491,7 +504,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; @@ -532,7 +545,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", + ) ); } diff --git a/apps/web/src/components/settings/AgentInstructionsSettings.tsx b/apps/web/src/components/settings/AgentInstructionsSettings.tsx index 554962527..f61b5d97d 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 ?? @@ -475,12 +481,13 @@ export function AgentInstructionsSettingsPanel() { {projectOptions.map((project) => { const projectEnvironmentId = environmentIdByCwd.get(project.value); return ( - + {projectEnvironmentId ? ( ) : null} {project.label} @@ -531,7 +538,7 @@ export function AgentInstructionsSettingsPanel() { {instructionFiles.map((file) => { 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 d2c3dfd02..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} ))} @@ -4583,7 +4583,13 @@ export function ExtensionsSettingsPanel() { {selectedEnvironmentId ? ( - + project.value === cwd)?.label ?? "" + } + /> ) : null} {projectOptions.find((project) => project.value === cwd)?.label ?? @@ -4596,12 +4602,13 @@ export function ExtensionsSettingsPanel() { {projectOptions.map((project) => { const projectEnvironmentId = environmentIdByCwd.get(project.value); return ( - + {projectEnvironmentId ? ( ) : 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..9c0ae3503 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -309,12 +309,8 @@ function AboutVersionSection() { - - 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)} ))} @@ -2218,7 +2204,13 @@ export function ArchivedThreadsPanel() { } + icon={ + + } > {projectThreads.map((thread) => ( {WRITING_STYLE_OPTIONS.map((option) => ( - + {option.label} ))} diff --git a/apps/web/src/components/sidebar/InboxRows.tsx b/apps/web/src/components/sidebar/InboxRows.tsx index 7267dcdbb..d86c8f198 100644 --- a/apps/web/src/components/sidebar/InboxRows.tsx +++ b/apps/web/src/components/sidebar/InboxRows.tsx @@ -8,6 +8,7 @@ import { GitBranchIcon, } from "lucide-react"; import React, { memo, useCallback, useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; import type { ScopedThreadRef } from "@threadlines/contracts"; import { scopedThreadKey, scopeProjectRef, scopeThreadRef } from "@threadlines/client-runtime"; import { resolveThreadWorkingCwd } from "@threadlines/shared/threadCwd"; @@ -117,21 +118,60 @@ function RowFloatingActions(props: { } /** - * The thread's own project cwd. Grouped projects put threads from several - * checkouts under one name, so the row asks for its own rather than the - * group's -- the favicon and the git status both depend on the right one. + * The thread's own project. Grouped projects put threads from several checkouts + * under one name, so the row asks for its own rather than the group's -- the + * favicon, its monogram fallback, and the git status all depend on the right + * one. */ -function useThreadProjectCwd(thread: SidebarThreadSummary): string | null { +function useThreadProject(thread: SidebarThreadSummary): { cwd: string; name: string } | null { return useStore( - useMemo( - () => (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 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 }) { + 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} + + ); +} + function formatDiffCount(count: number): string { return count >= 1_000 ? `${Math.round(count / 100) / 10}k` : `${count}`; } @@ -231,21 +271,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 +464,11 @@ export const InboxThreadRow = memo(function InboxThreadRow(props: InboxThreadRow ) : null} - {threadProjectCwd ? ( + {threadProject ? ( ) : null} @@ -556,21 +585,7 @@ export const InboxThreadRow = memo(function InboxThreadRow(props: InboxThreadRow /> ) : null} - {isRemoteThread ? ( - - - } - > - - - {threadEnvironmentLabel} - - ) : null} + {prStatus ? ( @@ -797,6 +813,7 @@ export const InboxDoneRow = memo(function InboxDoneRow(props: InboxDoneRowProps) {thread.title} + {terminalStatus ? ( [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(); + // 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 46cc01fd0..7cdbc7dd3 100644 --- a/apps/web/src/components/sidebar/ProjectScopeMenu.tsx +++ b/apps/web/src/components/sidebar/ProjectScopeMenu.tsx @@ -1,12 +1,19 @@ import { ChevronsUpDownIcon, + CloudIcon, EllipsisIcon, FolderIcon, FolderPlusIcon, + MonitorIcon, + MonitorSmartphoneIcon, SquarePenIcon, } from "lucide-react"; import React, { memo, useCallback, useState } from "react"; -import type { ContextMenuItem, SidebarProjectGroupingMode } from "@threadlines/contracts"; +import type { + ContextMenuItem, + EnvironmentId, + SidebarProjectGroupingMode, +} from "@threadlines/contracts"; import { scopeProjectRef } from "@threadlines/client-runtime"; import { cn, newCommandId } from "../../lib/utils"; @@ -37,7 +44,17 @@ import { DialogTitle, } from "../ui/dialog"; import { Input } from "../ui/input"; -import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; +import { + Menu, + MENU_PICK_ITEM_CLASS_NAME, + MENU_PICK_ITEM_SELECTED_CLASS_NAME, + 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"; @@ -70,19 +87,25 @@ 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 { + environmentId: EnvironmentId; + label: string; + isPrimary: boolean; +} export interface ProjectScopeMenuProps { options: readonly ProjectScopeOption[]; projectByKey: ReadonlyMap; 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 +126,9 @@ export const ProjectScopeMenu = memo(function ProjectScopeMenu(props: ProjectSco projectByKey, scopedProjectKey, onScopeChange, + environmentOptions, + scopedEnvironmentId, + onEnvironmentScopeChange, onAddProject, onNewThread, newThreadShortcutLabel, @@ -466,6 +492,13 @@ 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 scopedEnvironment = + showEnvironmentScope && scopedEnvironmentId !== null + ? (environmentOptions.find((option) => option.environmentId === scopedEnvironmentId) ?? null) + : null; return ( <> @@ -489,13 +522,26 @@ export const ProjectScopeMenu = memo(function ProjectScopeMenu(props: ProjectSco + ) : 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"} + {scopedProject + ? `${scopedProject.displayName}${scopedEnvironment ? ` · ${scopedEnvironment.label}` : ""}` + : (scopedEnvironment?.label ?? "All projects")} @@ -536,6 +582,7 @@ export const ProjectScopeMenu = memo(function ProjectScopeMenu(props: ProjectSco ) : ( @@ -567,6 +614,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} @@ -698,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/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..7c37a3f58 100644 --- a/apps/web/src/components/sidebar/ThreadHoverCard.tsx +++ b/apps/web/src/components/sidebar/ThreadHoverCard.tsx @@ -1,5 +1,5 @@ import { scopeProjectRef, scopeThreadRef } from "@threadlines/client-runtime"; -import { GitBranchIcon, LaptopIcon, ServerIcon } from "lucide-react"; +import { CloudIcon, GitBranchIcon, MonitorIcon } from "lucide-react"; import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; import { PROVIDER_ICON_BY_PROVIDER } from "../chat/providerIconUtils"; @@ -148,15 +148,24 @@ function ThreadHoverCardContent({ thread, status }: ThreadHoverCardPayload) { {project ? ( } + icon={ + + } > {project.name} ) : null} {environmentLabel ? ( : + isRemote ? : } > {environmentLabel} 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/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 ( - {hideIndicator ? null : ( - - - - - - )} - + {children} 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/index.css b/apps/web/src/index.css index 0dc127ce7..1d034c9fe 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -66,6 +66,7 @@ --color-destructive: var(--destructive); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); + --color-pick-selected: var(--pick-selected); --color-muted-foreground: var(--muted-foreground); --color-muted: var(--muted); --color-secondary-foreground: var(--secondary-foreground); @@ -223,6 +224,10 @@ --muted-foreground: oklch(0.5 0 0); --accent: --alpha(var(--color-black) / 4%); --accent-foreground: var(--foreground); + /* Resting fill of the chosen row in value pickers. Kept faint on purpose: + the selected row is set apart from the borderless hover wash by a hairline + inset ring (--border), a difference in structure rather than in shade. */ + --pick-selected: --alpha(var(--color-black) / 3%); --control-active: --alpha(var(--color-black) / 9%); --destructive: var(--color-red-500); --border: --alpha(var(--color-black) / 8%); @@ -283,6 +288,7 @@ --muted-foreground: oklch(0.68 0 0); --accent: --alpha(var(--color-white) / 9%); --accent-foreground: var(--foreground); + --pick-selected: --alpha(var(--color-white) / 4%); --control-active: --alpha(var(--color-white) / 17%); --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white)); --border: --alpha(var(--color-white) / 14%); 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..5ace30d12 --- /dev/null +++ b/apps/web/src/sidebarProjectGrouping.test.ts @@ -0,0 +1,115 @@ +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", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/example/shared.git", + }, + 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) =>