@@ -86,6 +111,7 @@ export function DraftEmptyState({
) : null}
{targetName}
@@ -96,15 +122,16 @@ export function DraftEmptyState({
<>
@@ -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 (
);
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
+
+ {environmentOptions.map((option) => (
+
+ ))}
+
+ ) : 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: {