+
+Sidebar brainstorm — working set variants
+
+
+
+
+
Sidebar brainstorm: keeping 2–3 live threads findable
+
+ Three variants. Shared premise: the handful of threads you're actively driving get a
+ stable home that never reshuffles under you, and the project tree becomes the browser
+ for everything else. Dark only, hairlines not cards, per the design system.
+
+
+
+
+
+
+
A · On Deck + browser
+
Flat cross-project working set pinned to the top. Rows keep insertion
+ order (never resort on activity); only the status dot changes. 1–5 jump keys map here.
+
+
On deck
+
1Fix reconnect race in ws.tsthreadlines
+
2Marketing footer copy passthreadlines
+
3pgvector migrationnutrilog
+
+
Projects
+
threadlines
+
nutrilog4
+
scraper-old12
+
dotfiles2
+
+
+
+
+
+
+
+
B · Focused projects
+
You focus 1–2 projects; only those expand. Everything else collapses
+ into one "Other projects" row. Closest to the current tree, least new machinery.
+
+
threadlines
+
Fix reconnect race in ws.ts
+
Marketing footer copy pass
+
Design tokens cleanup2d
+
Show more (14)
+
+
nutrilog
+
pgvector migration
+
Show more (3)
+
+
Other projects4
+
+
+
+
+
+
+
+
C · Attention lanes
+
The working set grouped by what it wants from you: needs-you first,
+ then running, then recently settled. Reads like a queue; rows move between lanes.
+
+
Needs you
+
Marketing footer copy passthreadlines
+
Running
+
Fix reconnect race in ws.tsthreadlines
+
Settled
+
pgvector migrationnutrilog
+
Design tokens cleanupthreadlines
+
+
Projects
+
threadlines
+
nutrilog4
+
scraper-old12
+
+
+
+
+
+
+
+
+
From 35337b99cec89dc735afec78c47c84c44374aa93 Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Wed, 22 Jul 2026 15:32:17 -0400
Subject: [PATCH 02/82] Add On Deck working-set section above the sidebar
project tree
Threads enter the deck when they go live (working, awaiting input,
pending approval, plan ready, background), have an unseen completion,
or are pinned. Rows keep insertion order - activity only changes the
status dot, never the position - and stay until dismissed, archived,
or auto-trimmed (settled, seen, unpinned rows beyond the cap). Deck
membership and dismissals persist in ui-state localStorage. Deck rows
lead the jump-key and thread-traversal order, deduped against their
project-tree rows.
---
apps/web/src/components/Sidebar.logic.test.ts | 34 ++
apps/web/src/components/Sidebar.logic.ts | 30 ++
apps/web/src/components/Sidebar.tsx | 188 +++++--
.../src/components/sidebar/OnDeckSection.tsx | 185 +++++++
apps/web/src/uiStateStore.test.ts | 95 ++++
apps/web/src/uiStateStore.ts | 129 ++++-
docs/design/sidebar-working-set.html | 469 ++++++++++++------
7 files changed, 934 insertions(+), 196 deletions(-)
create mode 100644 apps/web/src/components/sidebar/OnDeckSection.tsx
diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts
index 3cee1f773..ac9f7fbc4 100644
--- a/apps/web/src/components/Sidebar.logic.test.ts
+++ b/apps/web/src/components/Sidebar.logic.test.ts
@@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
import { ProviderDriverKind } from "@threadlines/contracts";
import {
+ buildOnDeckSyncInput,
createThreadJumpHintVisibilityController,
getSidebarThreadIdsToPrewarm,
+ isOnDeckDismissible,
getVisibleSidebarThreadIds,
resolveAdjacentThreadId,
getFallbackThreadIdAfterDelete,
@@ -1238,3 +1240,35 @@ describe("sortProjectsForSidebar", () => {
expect(timestamp).toBe(Date.parse("2026-03-09T10:10:00.000Z"));
});
});
+
+describe("on deck classification", () => {
+ const pill = (label: import("./Sidebar.logic").ThreadStatusPill["label"]) => ({
+ label,
+ colorClass: "",
+ dotClass: "",
+ pulse: false,
+ });
+
+ it("buildOnDeckSyncInput separates live work from unseen completions", () => {
+ expect(
+ buildOnDeckSyncInput({ threadKey: "t-1", pinnedAt: null, status: pill("Working") }),
+ ).toEqual({ key: "t-1", pinned: false, live: true, unseen: false });
+ expect(
+ buildOnDeckSyncInput({ threadKey: "t-2", pinnedAt: null, status: pill("Completed") }),
+ ).toEqual({ key: "t-2", pinned: false, live: false, unseen: true });
+ expect(
+ buildOnDeckSyncInput({
+ threadKey: "t-3",
+ pinnedAt: "2026-03-09T10:00:00.000Z",
+ status: null,
+ }),
+ ).toEqual({ key: "t-3", pinned: true, live: false, unseen: false });
+ });
+
+ it("isOnDeckDismissible allows dismissing settled rows only", () => {
+ expect(isOnDeckDismissible(null)).toBe(true);
+ expect(isOnDeckDismissible(pill("Completed"))).toBe(true);
+ expect(isOnDeckDismissible(pill("Working"))).toBe(false);
+ expect(isOnDeckDismissible(pill("Pending Approval"))).toBe(false);
+ });
+});
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index e665cd747..263ce83ec 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -10,6 +10,7 @@ import {
type ThreadSortInput,
} from "../lib/threadSort";
import type { SidebarThreadSummary, Thread } from "../types";
+import type { OnDeckSyncInput } from "../uiStateStore";
import { cn } from "../lib/utils";
import { isLatestTurnSettled } from "../session-logic";
@@ -421,6 +422,35 @@ export function resolveThreadStatusPill(input: {
return null;
}
+/** Statuses that mean the provider is working or waiting on the user right now. */
+const ON_DECK_LIVE_STATUSES: ReadonlySet = new Set([
+ "Pending Approval",
+ "Awaiting Input",
+ "Working",
+ "Starting",
+ "Plan Ready",
+ "Background",
+]);
+
+/** Maps a thread's status pill and pin state onto the deck's entry signals. */
+export function buildOnDeckSyncInput(input: {
+ threadKey: string;
+ pinnedAt: string | null;
+ status: ThreadStatusPill | null;
+}): OnDeckSyncInput {
+ return {
+ key: input.threadKey,
+ pinned: input.pinnedAt !== null,
+ live: input.status !== null && ON_DECK_LIVE_STATUSES.has(input.status.label),
+ unseen: input.status?.label === "Completed",
+ };
+}
+
+/** Only settled rows offer the dismiss affordance; live work can't be waved away. */
+export function isOnDeckDismissible(status: ThreadStatusPill | null): boolean {
+ return status === null || !ON_DECK_LIVE_STATUSES.has(status.label);
+}
+
export function resolveProjectStatusIndicator(
statuses: ReadonlyArray,
): ThreadStatusPill | null {
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index dcac7ea7e..be3415b3b 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -157,8 +157,10 @@ import {
import { useThreadSelectionStore } from "../threadSelectionStore";
import { useCommandPaletteStore } from "../commandPaletteStore";
import {
+ buildOnDeckSyncInput,
getSidebarThreadIdsToPrewarm,
getSidebarThreadWindow,
+ isOnDeckDismissible,
resolveAdjacentThreadId,
isContextMenuPointerDown,
resolveProjectStatusIndicator,
@@ -172,6 +174,7 @@ import {
useThreadJumpHintVisibility,
ThreadStatusPill,
} from "./Sidebar.logic";
+import { OnDeckSection, type OnDeckEntry } from "./sidebar/OnDeckSection";
import { startNewGeneralChatThread } from "../lib/chatThreadActions";
import { sortThreads } from "../lib/threadSort";
import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
@@ -2753,6 +2756,8 @@ interface SidebarProjectsContentProps {
threadPreviewCount: SidebarThreadPreviewCount;
updateSettings: ReturnType["updateSettings"];
openAddProject: () => void;
+ /** The On Deck working-set group, rendered between search and the tree. */
+ onDeckSection: React.ReactNode;
generalChatsProjectRef: ScopedProjectRef | null;
/** Pinned above the Projects section; null while there are no general chats. */
generalChatsProject: SidebarProjectSnapshot | null;
@@ -2794,6 +2799,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
threadPreviewCount,
updateSettings,
openAddProject,
+ onDeckSection,
generalChatsProjectRef,
generalChatsProject,
isManualProjectSorting,
@@ -2875,6 +2881,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
+ {onDeckSection}
{/* The slot below search is always the General Chats home: a quiet
new-chat action row until the first chat exists, then the group
itself (whose hover button takes over starting new chats). */}
@@ -3070,6 +3077,10 @@ export default function Sidebar() {
const projectExpandedById = useUiStateStore((store) => store.projectExpandedById);
const projectOrder = useUiStateStore((store) => store.projectOrder);
const reorderProjects = useUiStateStore((store) => store.reorderProjects);
+ const threadLastVisitedAtById = useUiStateStore((store) => store.threadLastVisitedAtById);
+ const onDeckThreadKeys = useUiStateStore((store) => store.onDeckThreadKeys);
+ const syncOnDeck = useUiStateStore((store) => store.syncOnDeck);
+ const dismissFromOnDeck = useUiStateStore((store) => store.dismissFromOnDeck);
const navigate = useNavigate();
const pathname = useLocation({ select: (loc) => loc.pathname });
const isOnSettings = pathname.startsWith("/settings");
@@ -3364,47 +3375,128 @@ export default function Sidebar() {
return hasVisibleThreads ? snapshot : null;
}, [sidebarProjects, threadsByProjectKey]);
const isManualProjectSorting = sidebarProjectSortOrder === "manual";
- const visibleSidebarThreadKeys = useMemo(
- () =>
- [...(generalChatsSnapshot ? [generalChatsSnapshot] : []), ...sortedProjects].flatMap(
- (project) => {
- const projectExpanded = projectExpandedById[project.projectKey] ?? true;
- if (!projectExpanded) {
- return [];
- }
- const projectThreads = sortThreads(
- (threadsByProjectKey.get(project.projectKey) ?? []).filter(
- (thread) => thread.archivedAt === null,
- ),
- sidebarThreadSortOrder,
- );
- // Mirrors SidebarProjectItem's rendering window so jump labels and
- // thread traversal line up with the rows that are actually visible.
- const { visibleThreads } = getSidebarThreadWindow({
- threads: projectThreads,
- getThreadKey: (thread) =>
- scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
- activeThreadKey: project.projectKey === activeRouteProjectKey ? routeThreadKey : null,
- previewLimit: sidebarThreadPreviewCount,
- revealedCount: revealedThreadCountsByProject.get(project.projectKey) ?? 0,
- });
- return visibleThreads.map((thread) =>
- scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
- );
+
+ // On Deck: the cross-project working set pinned above the project browser.
+ // Status pills are computed once per visible thread and shared between the
+ // deck reconcile (store) and the rendered deck rows.
+ const onDeckStatusByThreadKey = useMemo(() => {
+ const statuses = new Map();
+ for (const thread of visibleThreads) {
+ const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+ const lastVisitedAt = threadLastVisitedAtById[threadKey];
+ statuses.set(
+ threadKey,
+ resolveThreadStatusPill({
+ thread: {
+ ...thread,
+ ...(lastVisitedAt !== undefined ? { lastVisitedAt } : {}),
+ },
+ }),
+ );
+ }
+ return statuses;
+ }, [threadLastVisitedAtById, visibleThreads]);
+
+ useEffect(() => {
+ syncOnDeck(
+ visibleThreads.map((thread) => {
+ const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+ return buildOnDeckSyncInput({
+ threadKey,
+ pinnedAt: thread.pinnedAt,
+ status: onDeckStatusByThreadKey.get(threadKey) ?? null,
+ });
+ }),
+ routeThreadKey,
+ );
+ }, [onDeckStatusByThreadKey, routeThreadKey, syncOnDeck, visibleThreads]);
+
+ const onDeckEntries = useMemo(() => {
+ return onDeckThreadKeys.flatMap((threadKey) => {
+ const thread = sidebarThreadByKey.get(threadKey);
+ if (!thread || thread.archivedAt !== null) {
+ return [];
+ }
+ const status = onDeckStatusByThreadKey.get(threadKey) ?? null;
+ const physicalKey =
+ projectPhysicalKeyByScopedRef.get(
+ scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
+ ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
+ const project = sidebarProjectByKey.get(physicalToLogicalKey.get(physicalKey) ?? physicalKey);
+ return [
+ {
+ thread,
+ status,
+ projectLabel: project?.kind === "general-chat" ? "chats" : (project?.displayName ?? null),
+ dismissible: isOnDeckDismissible(status),
},
+ ];
+ });
+ }, [
+ onDeckStatusByThreadKey,
+ onDeckThreadKeys,
+ physicalToLogicalKey,
+ projectPhysicalKeyByScopedRef,
+ sidebarProjectByKey,
+ sidebarThreadByKey,
+ ]);
+ const onDeckVisibleThreadKeys = useMemo(
+ () =>
+ onDeckEntries.map((entry) =>
+ scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)),
),
- [
- activeRouteProjectKey,
- generalChatsSnapshot,
- routeThreadKey,
- sidebarThreadSortOrder,
- sidebarThreadPreviewCount,
- revealedThreadCountsByProject,
- projectExpandedById,
- sortedProjects,
- threadsByProjectKey,
- ],
+ [onDeckEntries],
);
+
+ const visibleSidebarThreadKeys = useMemo(() => {
+ const projectThreadKeys = [
+ ...(generalChatsSnapshot ? [generalChatsSnapshot] : []),
+ ...sortedProjects,
+ ].flatMap((project) => {
+ const projectExpanded = projectExpandedById[project.projectKey] ?? true;
+ if (!projectExpanded) {
+ return [];
+ }
+ const projectThreads = sortThreads(
+ (threadsByProjectKey.get(project.projectKey) ?? []).filter(
+ (thread) => thread.archivedAt === null,
+ ),
+ sidebarThreadSortOrder,
+ );
+ // Mirrors SidebarProjectItem's rendering window so jump labels and
+ // thread traversal line up with the rows that are actually visible.
+ const { visibleThreads } = getSidebarThreadWindow({
+ threads: projectThreads,
+ getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ activeThreadKey: project.projectKey === activeRouteProjectKey ? routeThreadKey : null,
+ previewLimit: sidebarThreadPreviewCount,
+ revealedCount: revealedThreadCountsByProject.get(project.projectKey) ?? 0,
+ });
+ return visibleThreads.map((thread) =>
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ );
+ });
+ // Deck rows render above the tree, so they lead the visual order that jump
+ // keys and thread traversal follow. A thread on deck keeps one identity:
+ // its project-tree row is deduped here so jump indices stay aligned (both
+ // rows show the deck position's label).
+ const onDeckKeySet = new Set(onDeckVisibleThreadKeys);
+ return [
+ ...onDeckVisibleThreadKeys,
+ ...projectThreadKeys.filter((threadKey) => !onDeckKeySet.has(threadKey)),
+ ];
+ }, [
+ activeRouteProjectKey,
+ generalChatsSnapshot,
+ onDeckVisibleThreadKeys,
+ routeThreadKey,
+ sidebarThreadSortOrder,
+ sidebarThreadPreviewCount,
+ revealedThreadCountsByProject,
+ projectExpandedById,
+ sortedProjects,
+ threadsByProjectKey,
+ ]);
const threadJumpCommandByKey = useMemo(() => {
const mapping = new Map>>();
for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) {
@@ -3455,6 +3547,25 @@ export default function Sidebar() {
const visibleThreadJumpLabelByKey = showThreadJumpHints
? threadJumpLabelByKey
: EMPTY_THREAD_JUMP_LABELS;
+ const onDeckSection = useMemo(
+ () =>
+ onDeckEntries.length > 0 ? (
+
+ ) : null,
+ [
+ dismissFromOnDeck,
+ navigateToThread,
+ onDeckEntries,
+ routeThreadKey,
+ visibleThreadJumpLabelByKey,
+ ],
+ );
const orderedSidebarThreadKeys = visibleSidebarThreadKeys;
const prewarmedSidebarThreadKeys = useMemo(
() => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys),
@@ -3623,6 +3734,7 @@ export default function Sidebar() {
threadPreviewCount={sidebarThreadPreviewCount}
updateSettings={updateSettings}
openAddProject={openAddProjectCommandPalette}
+ onDeckSection={onDeckSection}
generalChatsProjectRef={generalChatsProjectRef}
generalChatsProject={generalChatsSnapshot}
isManualProjectSorting={isManualProjectSorting}
diff --git a/apps/web/src/components/sidebar/OnDeckSection.tsx b/apps/web/src/components/sidebar/OnDeckSection.tsx
new file mode 100644
index 000000000..6bd0a2600
--- /dev/null
+++ b/apps/web/src/components/sidebar/OnDeckSection.tsx
@@ -0,0 +1,185 @@
+import { PinIcon, XIcon } from "lucide-react";
+import { memo, useCallback } from "react";
+import type { ScopedThreadRef } from "@threadlines/contracts";
+import { scopedThreadKey, scopeThreadRef } from "@threadlines/client-runtime";
+import type { SidebarThreadSummary } from "../../types";
+import { cn } from "../../lib/utils";
+import { resolveThreadRowClassName, type ThreadStatusPill } from "../Sidebar.logic";
+import { ThreadStatusLabel } from "../ThreadStatusIndicators";
+import { SectionLabel } from "../ui/threadline";
+import { SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+
+export interface OnDeckEntry {
+ thread: SidebarThreadSummary;
+ status: ThreadStatusPill | null;
+ projectLabel: string | null;
+ dismissible: boolean;
+}
+
+interface OnDeckRowProps {
+ entry: OnDeckEntry;
+ isActive: boolean;
+ jumpLabel: string | null;
+ navigateToThread: (threadRef: ScopedThreadRef) => void;
+ dismissFromOnDeck: (threadKey: string) => void;
+}
+
+const ON_DECK_ROW_BUTTON_RENDER = ;
+
+const OnDeckRow = memo(function OnDeckRow(props: OnDeckRowProps) {
+ const { entry, isActive, jumpLabel, navigateToThread, dismissFromOnDeck } = props;
+ const { thread, status, projectLabel, dismissible } = entry;
+ const threadRef = scopeThreadRef(thread.environmentId, thread.id);
+ const threadKey = scopedThreadKey(threadRef);
+
+ const handleClick = useCallback(() => {
+ navigateToThread(threadRef);
+ }, [navigateToThread, threadRef]);
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ navigateToThread(threadRef);
+ },
+ [navigateToThread, threadRef],
+ );
+ const handleDismissClick = useCallback(
+ (event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ dismissFromOnDeck(threadKey);
+ },
+ [dismissFromOnDeck, threadKey],
+ );
+ const stopPointerDown = useCallback((event: React.PointerEvent) => {
+ event.stopPropagation();
+ }, []);
+
+ return (
+
+
+
+ {status ? (
+
+ ) : (
+
+ )}
+
+ {thread.pinnedAt !== null && (
+
+
+
+ )}
+
+ {thread.title}
+
+
+ {jumpLabel ? (
+
+ {jumpLabel}
+
+ ) : null}
+ {projectLabel ? (
+
+ {projectLabel}
+
+ ) : null}
+
+ {dismissible && (
+
+
+
+
+ }
+ />
+ Remove from On Deck
+
+ )}
+
+
+ );
+});
+
+export interface OnDeckSectionProps {
+ entries: readonly OnDeckEntry[];
+ routeThreadKey: string | null;
+ threadJumpLabelByKey: ReadonlyMap;
+ navigateToThread: (threadRef: ScopedThreadRef) => void;
+ dismissFromOnDeck: (threadKey: string) => void;
+}
+
+/**
+ * The cross-project working set at the top of the sidebar. Rows keep their
+ * insertion order — agent activity only changes the status dot, never the
+ * position — so the threads being driven right now always live in the same
+ * spot. The project tree below remains the browser for everything else.
+ */
+export const OnDeckSection = memo(function OnDeckSection(props: OnDeckSectionProps) {
+ const { entries, routeThreadKey, threadJumpLabelByKey, navigateToThread, dismissFromOnDeck } =
+ props;
+ if (entries.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ On deck
+
+
+ {entries.map((entry) => {
+ const threadKey = scopedThreadKey(
+ scopeThreadRef(entry.thread.environmentId, entry.thread.id),
+ );
+ return (
+
+ );
+ })}
+
+
+ );
+});
diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts
index c9be00db5..64be831ca 100644
--- a/apps/web/src/uiStateStore.test.ts
+++ b/apps/web/src/uiStateStore.test.ts
@@ -3,9 +3,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
import {
clearThreadUi,
+ dismissFromOnDeck,
hydratePersistedProjectState,
markThreadVisited,
markThreadUnread,
+ ON_DECK_MAX_THREADS,
+ type OnDeckSyncInput,
PERSISTED_STATE_KEY,
type PersistedUiState,
persistState,
@@ -14,6 +17,7 @@ import {
setLastChatThreadRef,
setProjectExpanded,
setThreadChangedFilesExpanded,
+ syncOnDeck,
syncProjects,
syncThreads,
type UiState,
@@ -25,12 +29,18 @@ function makeUiState(overrides: Partial = {}): UiState {
projectOrder: [],
threadLastVisitedAtById: {},
threadChangedFilesExpandedById: {},
+ onDeckThreadKeys: [],
+ onDeckDismissedThreadKeys: [],
defaultAdvertisedEndpointKey: null,
lastChatThreadRef: null,
...overrides,
};
}
+function makeOnDeckInput(key: string, overrides: Partial = {}): OnDeckSyncInput {
+ return { key, pinned: false, live: false, unseen: false, ...overrides };
+}
+
describe("uiStateStore pure functions", () => {
it("markThreadVisited stores the provided server timestamp", () => {
const threadId = ThreadId.make("thread-1");
@@ -676,3 +686,88 @@ describe("uiStateStore persistence round-trip", () => {
expect(rehydrated.projectExpandedById[nextLogicalKey]).toBe(false);
});
});
+
+describe("on deck", () => {
+ it("syncOnDeck keeps insertion order stable while activity reorders the input", () => {
+ let state = syncOnDeck(
+ makeUiState(),
+ [makeOnDeckInput("t-a", { live: true }), makeOnDeckInput("t-b", { live: true })],
+ null,
+ );
+ expect(state.onDeckThreadKeys).toEqual(["t-a", "t-b"]);
+
+ // Later activity on t-b resorts the sidebar input; deck positions hold.
+ state = syncOnDeck(
+ state,
+ [makeOnDeckInput("t-b", { live: true }), makeOnDeckInput("t-a", { live: true })],
+ null,
+ );
+ expect(state.onDeckThreadKeys).toEqual(["t-a", "t-b"]);
+
+ // New live threads append at the bottom.
+ state = syncOnDeck(
+ state,
+ [
+ makeOnDeckInput("t-c", { live: true }),
+ makeOnDeckInput("t-b", { live: true }),
+ makeOnDeckInput("t-a", { live: true }),
+ ],
+ null,
+ );
+ expect(state.onDeckThreadKeys).toEqual(["t-a", "t-b", "t-c"]);
+ });
+
+ it("membership is sticky: settled threads stay until dismissed, and dismissal lifts when the thread goes live again", () => {
+ let state = syncOnDeck(makeUiState(), [makeOnDeckInput("t-a", { live: true })], null);
+ expect(state.onDeckThreadKeys).toEqual(["t-a"]);
+
+ // Thread settles and its completion is seen — still on deck.
+ state = syncOnDeck(state, [makeOnDeckInput("t-a")], null);
+ expect(state.onDeckThreadKeys).toEqual(["t-a"]);
+
+ state = dismissFromOnDeck(state, "t-a");
+ expect(state.onDeckThreadKeys).toEqual([]);
+
+ // An unseen completion alone does not resurrect a dismissed thread.
+ state = syncOnDeck(state, [makeOnDeckInput("t-a", { unseen: true })], null);
+ expect(state.onDeckThreadKeys).toEqual([]);
+
+ // Going live again clears the dismissal and re-enters the deck.
+ state = syncOnDeck(state, [makeOnDeckInput("t-a", { live: true })], null);
+ expect(state.onDeckThreadKeys).toEqual(["t-a"]);
+ expect(state.onDeckDismissedThreadKeys).toEqual([]);
+ });
+
+ it("syncOnDeck drops deck and dismissal entries for threads that no longer exist", () => {
+ const state = makeUiState({
+ onDeckThreadKeys: ["t-gone", "t-a"],
+ onDeckDismissedThreadKeys: ["t-gone-too"],
+ });
+
+ const next = syncOnDeck(state, [makeOnDeckInput("t-a")], null);
+
+ expect(next.onDeckThreadKeys).toEqual(["t-a"]);
+ expect(next.onDeckDismissedThreadKeys).toEqual([]);
+ });
+
+ it("syncOnDeck evicts oldest quiet rows beyond the cap but spares pinned, live, unseen, and active rows", () => {
+ const quietKeys = Array.from({ length: ON_DECK_MAX_THREADS + 1 }, (_, i) => `t-q${i}`);
+ const state = makeUiState({ onDeckThreadKeys: quietKeys });
+
+ const next = syncOnDeck(
+ state,
+ [...quietKeys.map((key) => makeOnDeckInput(key)), makeOnDeckInput("t-live", { live: true })],
+ "t-q0",
+ );
+
+ // 9 candidates, cap 7: the two oldest quiet rows go, except t-q0 which is
+ // the open thread, so t-q1 and t-q2 are evicted instead.
+ expect(next.onDeckThreadKeys).toEqual(["t-q0", ...quietKeys.slice(3), "t-live"]);
+ });
+
+ it("syncOnDeck returns the same state when nothing changed", () => {
+ const state = makeUiState({ onDeckThreadKeys: ["t-a"] });
+ const next = syncOnDeck(state, [makeOnDeckInput("t-a", { live: true })], null);
+ expect(next).toBe(state);
+ });
+});
diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts
index 2da70c3d5..3fd111aa1 100644
--- a/apps/web/src/uiStateStore.ts
+++ b/apps/web/src/uiStateStore.ts
@@ -23,6 +23,8 @@ export interface PersistedUiState {
projectOrderCwds?: string[];
defaultAdvertisedEndpointKey?: string | null;
threadChangedFilesExpandedById?: Record>;
+ onDeckThreadKeys?: string[];
+ onDeckDismissedThreadKeys?: string[];
}
export interface UiProjectState {
@@ -35,6 +37,13 @@ export interface UiThreadState {
threadChangedFilesExpandedById: Record>;
}
+export interface UiOnDeckState {
+ /** Scoped thread keys in deck insertion order — never resorted by activity. */
+ onDeckThreadKeys: string[];
+ /** Threads the user removed from the deck; suppressed until they go live again. */
+ onDeckDismissedThreadKeys: string[];
+}
+
export interface UiEndpointState {
defaultAdvertisedEndpointKey: string | null;
}
@@ -44,7 +53,7 @@ export interface UiNavigationState {
}
export interface UiState
- extends UiProjectState, UiThreadState, UiEndpointState, UiNavigationState {}
+ extends UiProjectState, UiThreadState, UiOnDeckState, UiEndpointState, UiNavigationState {}
export interface SyncProjectInput {
/** Physical project key (env + cwd). Used for manual sort order. */
@@ -64,6 +73,8 @@ const initialState: UiState = {
projectOrder: [],
threadLastVisitedAtById: {},
threadChangedFilesExpandedById: {},
+ onDeckThreadKeys: [],
+ onDeckDismissedThreadKeys: [],
defaultAdvertisedEndpointKey: null,
lastChatThreadRef: null,
};
@@ -110,12 +121,27 @@ function readPersistedState(): UiState {
threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded(
parsed.threadChangedFilesExpandedById,
),
+ onDeckThreadKeys: sanitizePersistedKeyList(parsed.onDeckThreadKeys),
+ onDeckDismissedThreadKeys: sanitizePersistedKeyList(parsed.onDeckDismissedThreadKeys),
};
} catch {
return initialState;
}
}
+function sanitizePersistedKeyList(value: string[] | undefined): string[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ const keys: string[] = [];
+ for (const key of value) {
+ if (typeof key === "string" && key.length > 0 && !keys.includes(key)) {
+ keys.push(key);
+ }
+ }
+ return keys;
+}
+
function sanitizePersistedThreadChangedFilesExpanded(
value: PersistedUiState["threadChangedFilesExpandedById"],
): Record> {
@@ -200,6 +226,8 @@ export function persistState(state: UiState): void {
projectOrderCwds,
defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey,
threadChangedFilesExpandedById,
+ onDeckThreadKeys: state.onDeckThreadKeys,
+ onDeckDismissedThreadKeys: state.onDeckDismissedThreadKeys,
} satisfies PersistedUiState),
);
if (!legacyKeysCleanedUp) {
@@ -229,10 +257,8 @@ function recordsEqual(left: Record, right: Record): boo
return true;
}
-function projectOrdersEqual(left: readonly string[], right: readonly string[]): boolean {
- return (
- left.length === right.length && left.every((projectId, index) => projectId === right[index])
- );
+function orderedListsEqual(left: readonly string[], right: readonly string[]): boolean {
+ return left.length === right.length && left.every((entry, index) => entry === right[index]);
}
function nestedBooleanRecordsEqual(
@@ -390,7 +416,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
if (
recordsEqual(state.projectExpandedById, nextExpandedById) &&
- projectOrdersEqual(state.projectOrder, nextProjectOrder) &&
+ orderedListsEqual(state.projectOrder, nextProjectOrder) &&
!cwdMappingChanged
) {
return state;
@@ -440,6 +466,92 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[])
};
}
+export interface OnDeckSyncInput {
+ /** Scoped thread key. */
+ key: string;
+ pinned: boolean;
+ /** The provider is working or waiting on the user right now. */
+ live: boolean;
+ /** Settled with a completion the user hasn't seen yet. */
+ unseen: boolean;
+}
+
+/** Deck size the auto-trim steers toward; only quiet rows are evicted for it. */
+export const ON_DECK_MAX_THREADS = 7;
+
+/**
+ * Reconciles the deck against the current thread snapshots. Membership is
+ * sticky: rows keep their insertion position while agent activity only changes
+ * their status, so the working set never reshuffles under the user. Threads
+ * enter when they become live, unseen, or pinned; they leave when dismissed,
+ * archived, or auto-trimmed (settled, seen, unpinned rows beyond the cap,
+ * oldest first — never the active route thread).
+ */
+export function syncOnDeck(
+ state: UiState,
+ threads: readonly OnDeckSyncInput[],
+ activeThreadKey: string | null,
+): UiState {
+ const inputByKey = new Map(threads.map((thread) => [thread.key, thread] as const));
+ // Dismissal only holds while a thread stays settled: going live again always
+ // brings it back to the deck.
+ const nextDismissed = state.onDeckDismissedThreadKeys.filter((key) => {
+ const input = inputByKey.get(key);
+ return input !== undefined && !input.live;
+ });
+ const dismissedSet = new Set(nextDismissed);
+ const retained = state.onDeckThreadKeys.filter(
+ (key) => inputByKey.has(key) && !dismissedSet.has(key),
+ );
+ const retainedSet = new Set(retained);
+ const appended = threads
+ .filter(
+ (thread) =>
+ (thread.pinned || thread.live || thread.unseen) &&
+ !retainedSet.has(thread.key) &&
+ !dismissedSet.has(thread.key),
+ )
+ .map((thread) => thread.key);
+ let nextOrder = [...retained, ...appended];
+ if (nextOrder.length > ON_DECK_MAX_THREADS) {
+ const evictableKeys = nextOrder.filter((key) => {
+ if (key === activeThreadKey) {
+ return false;
+ }
+ const input = inputByKey.get(key);
+ return input !== undefined && !input.pinned && !input.live && !input.unseen;
+ });
+ const evicted = new Set(evictableKeys.slice(0, nextOrder.length - ON_DECK_MAX_THREADS));
+ if (evicted.size > 0) {
+ nextOrder = nextOrder.filter((key) => !evicted.has(key));
+ }
+ }
+ if (
+ orderedListsEqual(state.onDeckThreadKeys, nextOrder) &&
+ orderedListsEqual(state.onDeckDismissedThreadKeys, nextDismissed)
+ ) {
+ return state;
+ }
+ return {
+ ...state,
+ onDeckThreadKeys: nextOrder,
+ onDeckDismissedThreadKeys: nextDismissed,
+ };
+}
+
+export function dismissFromOnDeck(state: UiState, threadKey: string): UiState {
+ if (!state.onDeckThreadKeys.includes(threadKey)) {
+ return state;
+ }
+ return {
+ ...state,
+ onDeckThreadKeys: state.onDeckThreadKeys.filter((key) => key !== threadKey),
+ onDeckDismissedThreadKeys: state.onDeckDismissedThreadKeys.includes(threadKey)
+ ? state.onDeckDismissedThreadKeys
+ : [...state.onDeckDismissedThreadKeys, threadKey],
+ };
+}
+
export function markThreadVisited(state: UiState, threadId: string, visitedAt?: string): UiState {
const at = visitedAt ?? new Date().toISOString();
const visitedAtMs = Date.parse(at);
@@ -648,6 +760,8 @@ export function reorderProjects(
interface UiStateStore extends UiState {
syncProjects: (projects: readonly SyncProjectInput[]) => void;
syncThreads: (threads: readonly SyncThreadInput[]) => void;
+ syncOnDeck: (threads: readonly OnDeckSyncInput[], activeThreadKey: string | null) => void;
+ dismissFromOnDeck: (threadKey: string) => void;
markThreadVisited: (threadId: string, visitedAt?: string) => void;
markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
clearThreadUi: (threadId: string) => void;
@@ -671,6 +785,9 @@ export const useUiStateStore = create((set) => ({
...readPersistedState(),
syncProjects: (projects) => set((state) => syncProjects(state, projects)),
syncThreads: (threads) => set((state) => syncThreads(state, threads)),
+ syncOnDeck: (threads, activeThreadKey) =>
+ set((state) => syncOnDeck(state, threads, activeThreadKey)),
+ dismissFromOnDeck: (threadKey) => set((state) => dismissFromOnDeck(state, threadKey)),
markThreadVisited: (threadId, visitedAt) =>
set((state) => markThreadVisited(state, threadId, visitedAt)),
markThreadUnread: (threadId, latestTurnCompletedAt) =>
diff --git a/docs/design/sidebar-working-set.html b/docs/design/sidebar-working-set.html
index 59a88e135..10f6008ee 100644
--- a/docs/design/sidebar-working-set.html
+++ b/docs/design/sidebar-working-set.html
@@ -1,159 +1,324 @@
-
-
-Sidebar brainstorm — working set variants
-
-
-
+
+
+ Sidebar brainstorm — working set variants
+
+
+
+
Sidebar brainstorm: keeping 2–3 live threads findable
+
+ Three variants. Shared premise: the handful of threads you're actively driving get a stable
+ home that never reshuffles under you, and the project tree becomes the browser for everything
+ else. Dark only, hairlines not cards, per the design system.
+
-
Sidebar brainstorm: keeping 2–3 live threads findable
-
- Three variants. Shared premise: the handful of threads you're actively driving get a
- stable home that never reshuffles under you, and the project tree becomes the browser
- for everything else. Dark only, hairlines not cards, per the design system.
-
+
+
+
+
A · On Deck + browser
+
+ Flat cross-project working set pinned to the top. Rows keep insertion order (never resort
+ on activity); only the status dot changes. 1–5 jump keys map here.
+
+
+
On deck
+
+ 1Fix reconnect race in ws.tsthreadlines
+
+
+ 2Marketing footer copy passthreadlines
+
+
+ 3pgvector migrationnutrilog
+
+
+
Projects
+
+ threadlines
+
+
nutrilog4
+
+ scraper-old12
+
+
dotfiles2
+
+
+
+
-
+
+
+
B · Focused projects
+
+ You focus 1–2 projects; only those expand. Everything else collapses into one "Other
+ projects" row. Closest to the current tree, least new machinery.
+
+
+
+ threadlines
+
+
+ Fix reconnect race in ws.ts
+
+
+ Marketing footer copy pass
+
+
+ Design tokens cleanup2d
+
+
Show more (14)
+
+
nutrilog
+
+ pgvector migration
+
+
Show more (3)
+
+
+ Other projects4
+
+
+
+
+
-
-
-
A · On Deck + browser
-
Flat cross-project working set pinned to the top. Rows keep insertion
- order (never resort on activity); only the status dot changes. 1–5 jump keys map here.
-
-
On deck
-
1Fix reconnect race in ws.tsthreadlines
-
2Marketing footer copy passthreadlines
-
3pgvector migrationnutrilog
-
-
Projects
-
threadlines
-
nutrilog4
-
scraper-old12
-
dotfiles2
-
-
+
+
+
C · Attention lanes
+
+ The working set grouped by what it wants from you: needs-you first, then running, then
+ recently settled. Reads like a queue; rows move between lanes.
+
+
+
Needs you
+
+ Marketing footer copy passthreadlines
+
+
Running
+
+ Fix reconnect race in ws.tsthreadlines
+
+
Settled
+
+ pgvector migrationnutrilog
+
+
+ Design tokens cleanupthreadlines
+
+
+
Projects
+
+ threadlines
+
+
nutrilog4
+
+ scraper-old12
+
+
+
+
+
-
-
-
-
-
B · Focused projects
-
You focus 1–2 projects; only those expand. Everything else collapses
- into one "Other projects" row. Closest to the current tree, least new machinery.
-
-
threadlines
-
Fix reconnect race in ws.ts
-
Marketing footer copy pass
-
Design tokens cleanup2d
-
Show more (14)
-
-
nutrilog
-
pgvector migration
-
Show more (3)
-
-
Other projects4
-
-
-
-
-
-
-
-
C · Attention lanes
-
The working set grouped by what it wants from you: needs-you first,
- then running, then recently settled. Reads like a queue; rows move between lanes.
+ Three layouts for adding an agent-driven browser without demoting source control. Shared
+ premise: terminal stays bottom-docked, files stay a popover, and the browser is a live view of
+ a server-owned session the agent can drive with the panel open or closed.
+
▸ Dev Env
-
+
`;
}
From 341a9f6153a48dded780b2f90618526d60f838fb Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 01:20:46 -0400
Subject: [PATCH 05/82] Collapse the sidebar to a deck rail instead of
off-canvas
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The sidebar collapsed to nothing, so reclaiming width for a wide panel
meant losing every signal about the threads you were not looking at.
Collapse now lands on a 44px rail carrying the question the sidebar
answers while you are heads-down in one thread: is anything else
running, finished, or stuck?
The rail is a second density of the same column rather than a companion
to it — expanded shows the pane, collapsed shows the rail, never both —
so nothing is rendered twice. On Deck rows keep their expanded order,
so a dot never moves under the cursor, and an aggregate badge stands in
for the per-row attention signal that collapsing takes away.
Project glyphs are deliberately absent: per-project status is derived
inside each project row, and duplicating that derivation for the rail
would be worse than shipping without it.
---
apps/web/src/components/AppSidebarLayout.tsx | 6 +-
apps/web/src/components/Sidebar.logic.test.ts | 17 ++
apps/web/src/components/Sidebar.logic.ts | 27 +++
apps/web/src/components/Sidebar.tsx | 71 +++++++-
.../src/components/ThreadStatusIndicators.tsx | 27 +++
.../components/sidebar/DeckRail.browser.tsx | 109 ++++++++++++
apps/web/src/components/sidebar/DeckRail.tsx | 167 ++++++++++++++++++
7 files changed, 419 insertions(+), 5 deletions(-)
create mode 100644 apps/web/src/components/sidebar/DeckRail.browser.tsx
create mode 100644 apps/web/src/components/sidebar/DeckRail.tsx
diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx
index 34cb370a7..2812a2bef 100644
--- a/apps/web/src/components/AppSidebarLayout.tsx
+++ b/apps/web/src/components/AppSidebarLayout.tsx
@@ -25,6 +25,9 @@ const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
const THREAD_SIDEBAR_DEFAULT_WIDTH = `${THREAD_SIDEBAR_MIN_WIDTH}px`;
const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "88px";
+// Collapsed width of the deck rail. Wide enough for a 32px hit target plus the
+// breathing room that keeps status dots off the window edge.
+const THREAD_SIDEBAR_RAIL_WIDTH = "2.75rem";
function SidebarControl() {
return (
@@ -47,6 +50,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
const appSettings = useSettings();
const sidebarStyle = {
"--sidebar-width": THREAD_SIDEBAR_DEFAULT_WIDTH,
+ "--sidebar-width-icon": THREAD_SIDEBAR_RAIL_WIDTH,
...(isElectron && typeof navigator !== "undefined" && isMacPlatform(navigator.platform)
? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
: {}),
@@ -135,7 +139,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
{
expect(isOnDeckDismissible(pill("Working"))).toBe(false);
expect(isOnDeckDismissible(pill("Pending Approval"))).toBe(false);
});
+
+ it("countThreadsNeedingUser counts only threads blocked on the user", () => {
+ expect(
+ countThreadsNeedingUser([
+ pill("Pending Approval"),
+ pill("Awaiting Input"),
+ // Live but unblocked, so the rail badge must ignore these.
+ pill("Working"),
+ pill("Starting"),
+ pill("Plan Ready"),
+ pill("Completed"),
+ null,
+ ]),
+ ).toBe(2);
+ expect(countThreadsNeedingUser([])).toBe(0);
+ });
});
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index 263ce83ec..c0a4e98a7 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -451,6 +451,33 @@ export function isOnDeckDismissible(status: ThreadStatusPill | null): boolean {
return status === null || !ON_DECK_LIVE_STATUSES.has(status.label);
}
+/**
+ * Statuses where the agent has stopped and cannot continue without the user.
+ * Narrower than {@link ON_DECK_LIVE_STATUSES}: a working thread is live but
+ * needs nothing, and a ready plan is an invitation rather than a block.
+ */
+const NEEDS_USER_STATUSES: ReadonlySet = new Set([
+ "Pending Approval",
+ "Awaiting Input",
+]);
+
+/** True when the thread is blocked waiting on the user. */
+export function isNeedsUserStatus(status: ThreadStatusPill | null): boolean {
+ return status !== null && NEEDS_USER_STATUSES.has(status.label);
+}
+
+/**
+ * How many threads are blocked on the user. The collapsed sidebar rail drops
+ * per-thread titles, so this aggregate is the only attention signal left.
+ */
+export function countThreadsNeedingUser(statuses: ReadonlyArray): number {
+ let count = 0;
+ for (const status of statuses) {
+ if (isNeedsUserStatus(status)) count += 1;
+ }
+ return count;
+}
+
export function resolveProjectStatusIndicator(
statuses: ReadonlyArray,
): ThreadStatusPill | null {
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index be3415b3b..620fc7437 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -97,7 +97,7 @@ import { useShortcutModifierState } from "../shortcutModifierState";
import { useGitStatus } from "../lib/gitStatusState";
import { readLocalApi } from "../localApi";
import { useComposerDraftStore } from "../composerDraftStore";
-import { useNewThreadHandler } from "../hooks/useHandleNewThread";
+import { useHandleNewThread, useNewThreadHandler } from "../hooks/useHandleNewThread";
import { retainThreadDetailSubscription } from "../environments/runtime/service";
import { useThreadActions } from "../hooks/useThreadActions";
@@ -175,7 +175,8 @@ import {
ThreadStatusPill,
} from "./Sidebar.logic";
import { OnDeckSection, type OnDeckEntry } from "./sidebar/OnDeckSection";
-import { startNewGeneralChatThread } from "../lib/chatThreadActions";
+import { DeckRail } from "./sidebar/DeckRail";
+import { startNewGeneralChatThread, startNewThreadFromContext } from "../lib/chatThreadActions";
import { sortThreads } from "../lib/threadSort";
import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
import { SidebarVersionTag } from "./sidebar/SidebarVersionTag";
@@ -2663,8 +2664,10 @@ function SortableProjectItem({
const SidebarChromeHeader = memo(function SidebarChromeHeader({
isElectron,
+ collapsed = false,
}: {
isElectron: boolean;
+ collapsed?: boolean;
}) {
const electronWordmarkLayout = resolveElectronSidebarWordmarkLayout(
typeof navigator === "undefined" ? "" : navigator.platform,
@@ -2700,6 +2703,20 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({
);
+ // The rail is too narrow for the wordmark, and its top-left is already the
+ // collapse trigger's fixed home — so the collapsed header is a bare spacer
+ // that keeps the titlebar height (and, on Electron, the drag region).
+ if (collapsed) {
+ return (
+
+ );
+ }
+
return isElectron ? (
{electronWordmarkLayout.spacerClassName ? (
@@ -3090,9 +3107,16 @@ export default function Sidebar() {
const projectGroupingSettings = useSettings(selectProjectGroupingSettings);
const sidebarThreadPreviewCount = useSettings((s) => s.sidebarThreadPreviewCount);
const { updateSettings } = useUpdateSettings();
- const { handleNewThread } = useNewThreadHandler();
+ // The full hook (rather than `useNewThreadHandler`) because the deck rail's
+ // new-thread button has to resolve the same default project and draft context
+ // the app menu's "New thread" action uses.
+ const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
+ useHandleNewThread();
+ const defaultThreadEnvMode = useSettings(
+ (settings) => settings.defaultThreadEnvMode,
+ );
const { archiveThread, deleteThread, pinThread, unpinThread } = useThreadActions();
- const { isMobile, setOpenMobile } = useSidebar();
+ const { isMobile, setOpen, setOpenMobile, state: sidebarState } = useSidebar();
const routeThreadRef = useParams({
strict: false,
select: (params) => resolveThreadRouteRef(params),
@@ -3100,6 +3124,7 @@ export default function Sidebar() {
const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
const keybindings = useServerKeybindings();
const openAddProjectCommandPalette = useCommandPaletteStore((store) => store.openAddProject);
+ const toggleCommandPalette = useCommandPaletteStore((store) => store.toggleOpen);
// Extra thread rows revealed beyond the preview, per project ("Show more").
const [revealedThreadCountsByProject, setRevealedThreadCountsByProject] = useState<
ReadonlyMap
@@ -3254,6 +3279,24 @@ export default function Sidebar() {
[clearSelection, isMobile, navigate, setOpenMobile, setSelectionAnchor],
);
+ const expandSidebar = useCallback(() => {
+ void setOpen(true);
+ }, [setOpen]);
+ const openSettings = useCallback(() => {
+ void navigate({ to: "/settings" });
+ }, [navigate]);
+ const startNewThreadFromRail = useCallback(() => {
+ void startNewThreadFromContext({
+ activeDraftThread,
+ activeThread,
+ defaultProjectRef,
+ defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({
+ defaultEnvMode: defaultThreadEnvMode,
+ }),
+ handleNewThread,
+ });
+ }, [activeDraftThread, activeThread, defaultProjectRef, defaultThreadEnvMode, handleNewThread]);
+
const projectDnDSensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
@@ -3717,6 +3760,26 @@ export default function Sidebar() {
});
}, [activeRouteProjectKey]);
+ // Collapsed desktop density: the deck rail replaces the pane entirely rather
+ // than sitting beside it, so nothing is shown twice. Mobile keeps using the
+ // off-canvas sheet, which has no collapsed state to render.
+ if (!isMobile && sidebarState === "collapsed") {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
return (
<>
diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx
index 85821b303..1f146329d 100644
--- a/apps/web/src/components/ThreadStatusIndicators.tsx
+++ b/apps/web/src/components/ThreadStatusIndicators.tsx
@@ -8,6 +8,7 @@ import {
useSavedEnvironmentRuntimeStore,
} from "../environments/runtime";
import { useGitStatus } from "../lib/gitStatusState";
+import { cn } from "../lib/utils";
import { type AppState, selectProjectByRef, useStore } from "../store";
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
import { useUiStateStore } from "../uiStateStore";
@@ -94,6 +95,32 @@ export function terminalStatusFromRunningIds(
};
}
+/**
+ * The bare status dot, without a tooltip or label. Callers that supply their
+ * own tooltip (the collapsed sidebar rail names the thread rather than the
+ * status) use this instead of {@link ThreadStatusLabel}.
+ */
+export function ThreadStatusDot({
+ status,
+ className,
+}: {
+ status: ThreadStatusPill | null;
+ className?: string;
+}) {
+ if (status === null) {
+ return ;
+ }
+ return status.pulse ? (
+
+ ) : (
+
+ );
+}
+
export function ThreadStatusLabel({
status,
compact = false,
diff --git a/apps/web/src/components/sidebar/DeckRail.browser.tsx b/apps/web/src/components/sidebar/DeckRail.browser.tsx
new file mode 100644
index 000000000..15a5b2652
--- /dev/null
+++ b/apps/web/src/components/sidebar/DeckRail.browser.tsx
@@ -0,0 +1,109 @@
+import { EnvironmentId, ProjectId, ThreadId } from "@threadlines/contracts";
+import { page } from "vite-plus/test/browser";
+import { describe, expect, it, vi } from "vite-plus/test";
+import { render } from "vitest-browser-react";
+
+import type { ThreadStatusPill } from "../Sidebar.logic";
+import type { SidebarThreadSummary } from "../../types";
+import { DeckRail } from "./DeckRail";
+import type { OnDeckEntry } from "./OnDeckSection";
+
+const ENVIRONMENT_ID = EnvironmentId.make("environment-local");
+
+function thread(id: string, title: string): SidebarThreadSummary {
+ return {
+ id: ThreadId.make(id),
+ environmentId: ENVIRONMENT_ID,
+ projectId: ProjectId.make("project-badcode"),
+ title,
+ interactionMode: "default",
+ session: null,
+ createdAt: "2026-07-23T00:00:00.000Z",
+ archivedAt: null,
+ pinnedAt: null,
+ latestTurn: null,
+ branch: null,
+ worktreePath: null,
+ latestUserMessageAt: null,
+ hasPendingApprovals: false,
+ hasPendingUserInput: false,
+ hasActionableProposedPlan: false,
+ };
+}
+
+function status(label: ThreadStatusPill["label"]): ThreadStatusPill {
+ return { label, colorClass: "", dotClass: "bg-amber-500", pulse: false };
+}
+
+function entry(
+ id: string,
+ title: string,
+ pill: ThreadStatusPill | null,
+ projectLabel: string | null = "badcode",
+): OnDeckEntry {
+ return { thread: thread(id, title), status: pill, projectLabel, dismissible: false };
+}
+
+function renderRail(entries: readonly OnDeckEntry[], overrides?: { routeThreadKey?: string }) {
+ const navigateToThread = vi.fn();
+ const expandSidebar = vi.fn();
+ const screen = render(
+ ,
+ );
+ return { expandSidebar, navigateToThread, screen };
+}
+
+describe("DeckRail", () => {
+ it("renders one dot per On Deck thread and navigates on click", async () => {
+ const { navigateToThread } = renderRail([
+ entry("thread-a", "Fix reconnect race", status("Working")),
+ entry("thread-b", "Marketing footer copy", status("Completed")),
+ ]);
+
+ await expect.element(page.getByTestId("deck-rail-thread-thread-a")).toBeInTheDocument();
+ await expect.element(page.getByTestId("deck-rail-thread-thread-b")).toBeInTheDocument();
+
+ await page.getByTestId("deck-rail-thread-thread-b").click();
+ expect(navigateToThread).toHaveBeenCalledWith(
+ expect.objectContaining({ threadId: "thread-b" }),
+ );
+ });
+
+ it("names the thread in place of the title the rail dropped", async () => {
+ renderRail([entry("thread-a", "Fix reconnect race", status("Awaiting Input"))]);
+
+ await expect
+ .element(page.getByTestId("deck-rail-thread-thread-a"))
+ .toHaveAttribute("aria-label", "Fix reconnect race · badcode · Awaiting Input");
+ });
+
+ it("badges how many threads are blocked on the user and expands the pane", async () => {
+ const { expandSidebar } = renderRail([
+ entry("thread-a", "Approve migration plan", status("Pending Approval")),
+ entry("thread-b", "Choose reconnect behavior", status("Awaiting Input")),
+ // Live but unblocked — must not be counted.
+ entry("thread-c", "Rebuild index", status("Working")),
+ ]);
+
+ const badge = page.getByTestId("deck-rail-needs-you");
+ await expect.element(badge).toHaveAttribute("aria-label", "2 threads need you");
+
+ await badge.click();
+ expect(expandSidebar).toHaveBeenCalledOnce();
+ });
+
+ it("omits the attention badge when nothing is waiting on the user", async () => {
+ renderRail([entry("thread-a", "Fix reconnect race", status("Working"))]);
+
+ await expect.element(page.getByTestId("deck-rail-search")).toBeInTheDocument();
+ expect(page.getByTestId("deck-rail-needs-you").query()).toBeNull();
+ });
+});
diff --git a/apps/web/src/components/sidebar/DeckRail.tsx b/apps/web/src/components/sidebar/DeckRail.tsx
new file mode 100644
index 000000000..57d41af94
--- /dev/null
+++ b/apps/web/src/components/sidebar/DeckRail.tsx
@@ -0,0 +1,167 @@
+import { BellDotIcon, SearchIcon, SettingsIcon, SquarePenIcon } from "lucide-react";
+import { memo, useCallback } from "react";
+import type { ScopedThreadRef } from "@threadlines/contracts";
+import { scopedThreadKey, scopeThreadRef } from "@threadlines/client-runtime";
+import { cn } from "../../lib/utils";
+import { countThreadsNeedingUser, isNeedsUserStatus } from "../Sidebar.logic";
+import { ThreadStatusDot } from "../ThreadStatusIndicators";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import type { OnDeckEntry } from "./OnDeckSection";
+
+const RAIL_BUTTON_CLASS_NAME =
+ "flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground/70 outline-hidden ring-ring transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-2";
+
+interface RailButtonProps {
+ label: string;
+ onClick: () => void;
+ testId?: string;
+ active?: boolean;
+ children: React.ReactNode;
+}
+
+function RailButton({ label, onClick, testId, active = false, children }: RailButtonProps) {
+ return (
+
+
+ {children}
+
+ }
+ />
+ {label}
+
+ );
+}
+
+export interface DeckRailProps {
+ entries: readonly OnDeckEntry[];
+ routeThreadKey: string | null;
+ navigateToThread: (threadRef: ScopedThreadRef) => void;
+ openSearch: () => void;
+ openSettings: () => void;
+ startNewThread: () => void;
+ expandSidebar: () => void;
+}
+
+/**
+ * The collapsed density of the left sidebar. Titles and the project tree are
+ * dropped; what survives is the question the sidebar answers while you are
+ * heads-down in one thread — is anything else running, finished, or stuck?
+ * On Deck rows keep their expanded order so a dot never moves under the
+ * cursor, and the aggregate "needs you" badge stands in for the per-row
+ * attention signal that collapsing takes away.
+ */
+export const DeckRail = memo(function DeckRail(props: DeckRailProps) {
+ const {
+ entries,
+ routeThreadKey,
+ navigateToThread,
+ openSearch,
+ openSettings,
+ startNewThread,
+ expandSidebar,
+ } = props;
+ const needsUserCount = countThreadsNeedingUser(entries.map((entry) => entry.status));
+
+ return (
+
+ );
+});
+
+function DeckRailThread(props: {
+ entry: OnDeckEntry;
+ routeThreadKey: string | null;
+ navigateToThread: (threadRef: ScopedThreadRef) => void;
+}) {
+ const { entry, routeThreadKey, navigateToThread } = props;
+ const threadRef = scopeThreadRef(entry.thread.environmentId, entry.thread.id);
+ const threadKey = scopedThreadKey(threadRef);
+ const isActive = routeThreadKey === threadKey;
+ const handleClick = useCallback(() => {
+ navigateToThread(threadRef);
+ }, [navigateToThread, threadRef]);
+
+ // The tooltip names the thread because the rail has dropped the title; the
+ // status is appended so the dot's colour never has to be decoded on its own.
+ const tooltip = [entry.thread.title, entry.projectLabel, entry.status ? entry.status.label : null]
+ .filter((part): part is string => part !== null && part.length > 0)
+ .join(" · ");
+
+ return (
+
+
+
+
+ }
+ />
+ {tooltip}
+
+ );
+}
From f0d3e9512edbf7769bf2a692148467c396914546 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 01:26:21 -0400
Subject: [PATCH 06/82] Show the working-tree diffstat on a closed source
control toggle
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
With a wide panel open, source control can sit far from its toggle, and
a bare icon says nothing about whether there is anything in the working
tree worth opening it for. The toggle now carries its own status.
The counts appear only while the panel is closed — once it is open it
lists per-file counts and repeating the total is noise — and only for a
dirty repository, so a clean tree stays visually quiet and an unloaded
status never reads as "no changes".
---
.../web/src/components/ChatView.logic.test.ts | 19 +++++++++++++++
apps/web/src/components/ChatView.logic.ts | 18 +++++++++++++++
apps/web/src/components/ChatView.tsx | 6 +++++
.../chat/ChatHeader.render.test.tsx | 23 +++++++++++++++++++
apps/web/src/components/chat/ChatHeader.tsx | 17 ++++++++++++++
5 files changed, 83 insertions(+)
diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts
index 9d1262677..4087ff742 100644
--- a/apps/web/src/components/ChatView.logic.test.ts
+++ b/apps/web/src/components/ChatView.logic.test.ts
@@ -16,6 +16,7 @@ import { type ChatMessage, type Thread } from "../types";
import {
buildRevertConfirmView,
+ resolveWorkingTreeDiffStat,
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
buildExpiredTerminalContextToastCopy,
classifyModelSwitch,
@@ -2418,3 +2419,21 @@ describe("buildRevertConfirmView", () => {
expect(view.notes.some((note) => note.startsWith("No active provider session"))).toBe(true);
});
});
+
+describe("resolveWorkingTreeDiffStat", () => {
+ it("reports counts only for a dirty repository", () => {
+ expect(
+ resolveWorkingTreeDiffStat({ isRepo: true, workingTree: { insertions: 38, deletions: 12 } }),
+ ).toEqual({ insertions: 38, deletions: 12 });
+ });
+
+ it("stays quiet for a clean tree, a non-repo, and an unloaded status", () => {
+ expect(
+ resolveWorkingTreeDiffStat({ isRepo: true, workingTree: { insertions: 0, deletions: 0 } }),
+ ).toBeNull();
+ expect(
+ resolveWorkingTreeDiffStat({ isRepo: false, workingTree: { insertions: 9, deletions: 9 } }),
+ ).toBeNull();
+ expect(resolveWorkingTreeDiffStat(null)).toBeNull();
+ });
+});
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
index b8f5e2377..d87254fed 100644
--- a/apps/web/src/components/ChatView.logic.ts
+++ b/apps/web/src/components/ChatView.logic.ts
@@ -1959,3 +1959,21 @@ export function buildRevertConfirmView(input: {
],
};
}
+
+/**
+ * The working-tree diffstat to show on a closed source control toggle, or null
+ * when there is nothing worth reporting. A clean tree stays visually quiet, and
+ * an unloaded or non-repo status must not read as "no changes".
+ */
+export function resolveWorkingTreeDiffStat(
+ status: {
+ readonly isRepo: boolean;
+ readonly workingTree: { readonly insertions: number; readonly deletions: number };
+ } | null,
+): { readonly insertions: number; readonly deletions: number } | null {
+ if (status === null || !status.isRepo) {
+ return null;
+ }
+ const { insertions, deletions } = status.workingTree;
+ return insertions === 0 && deletions === 0 ? null : { insertions, deletions };
+}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index e8f3dbe9a..0d123a201 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -244,6 +244,7 @@ import {
waitForStartedServerThread,
mergeLocalDraftThreadWithServerThread,
buildRevertConfirmView,
+ resolveWorkingTreeDiffStat,
type RevertConfirmView,
} from "./ChatView.logic";
import { useLocalStorage } from "~/hooks/useLocalStorage";
@@ -2503,6 +2504,10 @@ export default function ChatView(props: ChatViewProps) {
: (storeServerTerminalLaunchContext ?? null);
// Default true while loading to avoid toolbar flicker.
const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
+ const workingTreeDiffStat = useMemo(
+ () => resolveWorkingTreeDiffStat(gitStatusQuery.data ?? null),
+ [gitStatusQuery.data],
+ );
const terminalShortcutLabelOptions = useMemo(
() => ({
context: {
@@ -6003,6 +6008,7 @@ export default function ChatView(props: ChatViewProps) {
sourceControlToggleShortcutLabel={sourceControlPanelShortcutLabel}
sourceControlOpen={rightPanelEngaged && !isGeneralChatThread}
sourceControlAvailable={activeProject !== undefined && !isGeneralChatThread}
+ workingTreeDiffStat={workingTreeDiffStat}
fileBrowserAvailable={!isGeneralChatThread}
taskProgress={taskProgress}
subagentProgress={subagentProgress}
diff --git a/apps/web/src/components/chat/ChatHeader.render.test.tsx b/apps/web/src/components/chat/ChatHeader.render.test.tsx
index 6f1bc7c5b..2a4f725e5 100644
--- a/apps/web/src/components/chat/ChatHeader.render.test.tsx
+++ b/apps/web/src/components/chat/ChatHeader.render.test.tsx
@@ -26,6 +26,7 @@ function renderChatHeader(overrides: Partial>
sourceControlToggleShortcutLabel: null,
sourceControlOpen: false,
sourceControlAvailable: false,
+ workingTreeDiffStat: null,
fileBrowserAvailable: false,
taskProgress: null,
subagentProgress: null,
@@ -71,4 +72,26 @@ describe("ChatHeader", () => {
expect(markup).toContain('data-disabled="true"');
expect(markup).toContain("cursor-default");
});
+
+ it("shows the working-tree diffstat on the closed source control toggle", () => {
+ const markup = renderChatHeader({
+ sourceControlAvailable: true,
+ sourceControlOpen: false,
+ workingTreeDiffStat: { insertions: 38, deletions: 12 },
+ });
+
+ expect(markup).toContain("+38");
+ expect(markup).toContain("−12");
+ });
+
+ it("drops the diffstat once the panel is open and shows its own counts", () => {
+ const markup = renderChatHeader({
+ sourceControlAvailable: true,
+ sourceControlOpen: true,
+ workingTreeDiffStat: { insertions: 38, deletions: 12 },
+ });
+
+ expect(markup).not.toContain("+38");
+ expect(markup).not.toContain("−12");
+ });
});
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx
index 059a8dc1c..10bdbabd0 100644
--- a/apps/web/src/components/chat/ChatHeader.tsx
+++ b/apps/web/src/components/chat/ChatHeader.tsx
@@ -49,6 +49,12 @@ interface ChatHeaderProps {
sourceControlOpen: boolean;
/** False for capability-gated threads (General Chats) even when a project name exists. */
sourceControlAvailable: boolean;
+ /**
+ * Working-tree diffstat, surfaced on the closed source control toggle so the
+ * size of the pending change is legible without opening the panel. Null when
+ * the tree is clean or the status has not loaded.
+ */
+ workingTreeDiffStat: { readonly insertions: number; readonly deletions: number } | null;
/** False for General Chats: their scratch workspace has no files worth browsing. */
fileBrowserAvailable: boolean;
taskProgress: ThreadTaskProgressState | null;
@@ -112,6 +118,7 @@ export const ChatHeader = memo(function ChatHeader({
sourceControlToggleShortcutLabel,
sourceControlOpen,
sourceControlAvailable,
+ workingTreeDiffStat,
fileBrowserAvailable,
taskProgress,
subagentProgress,
@@ -314,6 +321,16 @@ export const ChatHeader = memo(function ChatHeader({
disabled={!sourceControlAvailable && !sourceControlOpen}
>
+ {/* Only while closed: once the panel is open it shows the
+ per-file counts, and repeating the total is noise. */}
+ {!sourceControlOpen && workingTreeDiffStat ? (
+
+ +{workingTreeDiffStat.insertions}
+
+ −{workingTreeDiffStat.deletions}
+
+
+ ) : null}
}
/>
From b7deb726d1dbeaa134e22ad00ea0d14bfa3bc1fd Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 01:33:37 -0400
Subject: [PATCH 07/82] Stop listing a thread in the project tree while it is
on deck
A thread on deck rendered a second row under its project, so the two
most prominent parts of the sidebar showed the same work twice and it
read as duplicate threads rather than one thread in two places.
Deck rows now own those threads outright. The filter runs before the
preview window so a deck thread does not consume a preview slot and
then vanish, which would leave "Show more" counts disagreeing with the
rows on screen, and the jump-label pass filters identically so keyboard
indices keep matching what is rendered.
Two things deliberately keep using the unfiltered list: the project's
aggregate status dot, because a project with a running thread is still
busy wherever that thread is displayed; and the "no threads yet" empty
state, because a project whose only threads are on deck does have
threads and simply renders no rows of its own.
---
apps/web/src/components/Sidebar.logic.test.ts | 26 ++++++++++++
apps/web/src/components/Sidebar.logic.ts | 17 ++++++++
apps/web/src/components/Sidebar.tsx | 42 ++++++++++++-------
3 files changed, 71 insertions(+), 14 deletions(-)
diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts
index 876abb7f3..3e0636a36 100644
--- a/apps/web/src/components/Sidebar.logic.test.ts
+++ b/apps/web/src/components/Sidebar.logic.test.ts
@@ -5,6 +5,7 @@ import {
buildOnDeckSyncInput,
countThreadsNeedingUser,
createThreadJumpHintVisibilityController,
+ excludeOnDeckThreads,
getSidebarThreadIdsToPrewarm,
isOnDeckDismissible,
getVisibleSidebarThreadIds,
@@ -1273,6 +1274,31 @@ describe("on deck classification", () => {
expect(isOnDeckDismissible(pill("Pending Approval"))).toBe(false);
});
+ it("excludeOnDeckThreads drops deck rows from the tree and keeps the rest in order", () => {
+ const threads = [{ key: "t-1" }, { key: "t-2" }, { key: "t-3" }];
+ const getThreadKey = (thread: { key: string }) => thread.key;
+
+ expect(
+ excludeOnDeckThreads({
+ threads,
+ onDeckThreadKeys: new Set(["t-2"]),
+ getThreadKey,
+ }),
+ ).toEqual([{ key: "t-1" }, { key: "t-3" }]);
+
+ expect(excludeOnDeckThreads({ threads, onDeckThreadKeys: new Set(), getThreadKey })).toEqual(
+ threads,
+ );
+
+ expect(
+ excludeOnDeckThreads({
+ threads,
+ onDeckThreadKeys: new Set(["t-1", "t-2", "t-3"]),
+ getThreadKey,
+ }),
+ ).toEqual([]);
+ });
+
it("countThreadsNeedingUser counts only threads blocked on the user", () => {
expect(
countThreadsNeedingUser([
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index c0a4e98a7..c5b195e2f 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -451,6 +451,23 @@ export function isOnDeckDismissible(status: ThreadStatusPill | null): boolean {
return status === null || !ON_DECK_LIVE_STATUSES.has(status.label);
}
+/**
+ * Drops threads that already have a deck row, so a thread on deck appears once
+ * in the sidebar rather than twice. Must be applied *before* the preview window
+ * is computed: a deck thread should not consume a preview slot and then vanish,
+ * which would leave "Show more" counts disagreeing with the rendered rows.
+ */
+export function excludeOnDeckThreads(input: {
+ threads: readonly TThread[];
+ onDeckThreadKeys: ReadonlySet;
+ getThreadKey: (thread: TThread) => string;
+}): TThread[] {
+ if (input.onDeckThreadKeys.size === 0) {
+ return [...input.threads];
+ }
+ return input.threads.filter((thread) => !input.onDeckThreadKeys.has(input.getThreadKey(thread)));
+}
+
/**
* Statuses where the agent has stopped and cannot continue without the user.
* Narrower than {@link ON_DECK_LIVE_STATUSES}: a working thread is live but
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 620fc7437..f5b6e01ae 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -158,6 +158,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
import { useCommandPaletteStore } from "../commandPaletteStore";
import {
buildOnDeckSyncInput,
+ excludeOnDeckThreads,
getSidebarThreadIdsToPrewarm,
getSidebarThreadWindow,
isOnDeckDismissible,
@@ -1202,6 +1203,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
const sidebarThreadByKeyRef = useRef(sidebarThreadByKey);
sidebarThreadByKeyRef.current = sidebarThreadByKey;
const projectThreads = sidebarThreads;
+ // Threads with a deck row are rendered there instead of here, so the same
+ // thread never appears twice in the sidebar.
+ const onDeckThreadKeys = useUiStateStore(useShallow((state) => state.onDeckThreadKeys));
+ const onDeckThreadKeySet = useMemo(() => new Set(onDeckThreadKeys), [onDeckThreadKeys]);
const projectExpanded = useUiStateStore(
(state) => state.projectExpandedById[project.projectKey] ?? true,
);
@@ -1316,8 +1321,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
},
});
};
- const threadWindow = getSidebarThreadWindow({
+ const treeThreads = excludeOnDeckThreads({
threads: visibleProjectThreads,
+ onDeckThreadKeys: onDeckThreadKeySet,
+ getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ });
+ const threadWindow = getSidebarThreadWindow({
+ threads: treeThreads,
getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
activeThreadKey: activeRouteThreadKey,
previewLimit: sidebarThreadPreviewCount,
@@ -1331,11 +1341,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
nextRevealCount: threadWindow.nextRevealCount,
searchHandoffThreadCount: threadWindow.searchHandoffThreadCount,
isThreadListRevealed: threadWindow.isRevealed,
+ // Deliberately the unfiltered list: a project whose only threads are on
+ // deck still has threads, so "no threads yet" would be a lie. It simply
+ // renders no rows of its own.
showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0,
shouldShowThreadPanel: projectExpanded,
};
}, [
activeRouteThreadKey,
+ onDeckThreadKeySet,
projectExpanded,
projectThreads,
revealedThreadCount,
@@ -3492,6 +3506,7 @@ export default function Sidebar() {
);
const visibleSidebarThreadKeys = useMemo(() => {
+ const onDeckKeySet = new Set(onDeckVisibleThreadKeys);
const projectThreadKeys = [
...(generalChatsSnapshot ? [generalChatsSnapshot] : []),
...sortedProjects,
@@ -3500,12 +3515,16 @@ export default function Sidebar() {
if (!projectExpanded) {
return [];
}
- const projectThreads = sortThreads(
- (threadsByProjectKey.get(project.projectKey) ?? []).filter(
- (thread) => thread.archivedAt === null,
+ const projectThreads = excludeOnDeckThreads({
+ threads: sortThreads(
+ (threadsByProjectKey.get(project.projectKey) ?? []).filter(
+ (thread) => thread.archivedAt === null,
+ ),
+ sidebarThreadSortOrder,
),
- sidebarThreadSortOrder,
- );
+ onDeckThreadKeys: onDeckKeySet,
+ getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ });
// Mirrors SidebarProjectItem's rendering window so jump labels and
// thread traversal line up with the rows that are actually visible.
const { visibleThreads } = getSidebarThreadWindow({
@@ -3520,14 +3539,9 @@ export default function Sidebar() {
);
});
// Deck rows render above the tree, so they lead the visual order that jump
- // keys and thread traversal follow. A thread on deck keeps one identity:
- // its project-tree row is deduped here so jump indices stay aligned (both
- // rows show the deck position's label).
- const onDeckKeySet = new Set(onDeckVisibleThreadKeys);
- return [
- ...onDeckVisibleThreadKeys,
- ...projectThreadKeys.filter((threadKey) => !onDeckKeySet.has(threadKey)),
- ];
+ // keys and thread traversal follow. The tree lists were already filtered
+ // above, so a deck thread has exactly one row and one jump index.
+ return [...onDeckVisibleThreadKeys, ...projectThreadKeys];
}, [
activeRouteProjectKey,
generalChatsSnapshot,
From 526bb3e04565cc511beb060aa0fc97022130214e Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 01:57:56 -0400
Subject: [PATCH 08/82] Give general chats a destination instead of a project
row
General Chats was a fixed place in the app wearing a project's clothes:
a tree group that expanded, carried a thread count, and sat directly
under On Deck. On Deck changes height as threads come and go, so that
was the one slot in the sidebar that moved the most.
Fixed navigation now lives in a band above the deck, where it can stay
in the same pixel every time, and general chats reach it as a Chats
destination with its own list view. The band is deliberately plain --
one line, no counts, no disclosure -- so it reads as chrome and never
competes with the live work below it. The collapsed rail renders the
same destinations as icons, so both densities offer the same places in
the same order.
The band holds one destination for now; Pull requests and Scheduled are
meant to join it, which is why the list is data-driven rather than
hand-rolled rows.
Two consequences worth naming: general chat threads no longer take jump
indices, because they no longer render a row in the tree; and the rail
keeps its own New thread button rather than promoting "New chat" into
the band, since the frequent action here starts a project thread, not a
general chat.
---
.../src/components/ChatsDestinationView.tsx | 122 ++++
apps/web/src/components/Sidebar.tsx | 119 +---
.../components/sidebar/DeckRail.browser.tsx | 23 +-
apps/web/src/components/sidebar/DeckRail.tsx | 40 +-
.../components/sidebar/DestinationBand.tsx | 60 ++
apps/web/src/routeTree.gen.ts | 21 +
apps/web/src/routes/_chat.chats.tsx | 7 +
docs/design/sidebar-destination-band.html | 628 ++++++++++++++++++
8 files changed, 929 insertions(+), 91 deletions(-)
create mode 100644 apps/web/src/components/ChatsDestinationView.tsx
create mode 100644 apps/web/src/components/sidebar/DestinationBand.tsx
create mode 100644 apps/web/src/routes/_chat.chats.tsx
create mode 100644 docs/design/sidebar-destination-band.html
diff --git a/apps/web/src/components/ChatsDestinationView.tsx b/apps/web/src/components/ChatsDestinationView.tsx
new file mode 100644
index 000000000..55d188e64
--- /dev/null
+++ b/apps/web/src/components/ChatsDestinationView.tsx
@@ -0,0 +1,122 @@
+import { scopeThreadRef } from "@threadlines/client-runtime";
+import { useNavigate } from "@tanstack/react-router";
+import { MessageCirclePlusIcon } from "lucide-react";
+import { useMemo } from "react";
+import { useShallow } from "zustand/react/shallow";
+
+import { useHandleNewThread } from "../hooks/useHandleNewThread";
+import { useSettings } from "../hooks/useSettings";
+import { startNewGeneralChatThread } from "../lib/chatThreadActions";
+import { resolveGeneralChatsProjectRef } from "../lib/generalChats";
+import { sortThreads } from "../lib/threadSort";
+import { usePrimaryEnvironmentId } from "../environments/primary";
+import {
+ selectGeneralChatsProjectAcrossEnvironments,
+ selectSidebarThreadsAcrossEnvironments,
+ useStore,
+} from "../store";
+import { buildThreadRouteParams } from "../threadRoutes";
+import { formatRelativeTimeLabel } from "../timestampFormat";
+import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators";
+
+/**
+ * The Chats destination: general chats are threads with no project, so they get
+ * a place of their own rather than a project-shaped group in the sidebar tree.
+ */
+export function ChatsDestinationView() {
+ const navigate = useNavigate();
+ const { handleNewThread } = useHandleNewThread();
+ const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments));
+ const generalChatsProject = useStore(selectGeneralChatsProjectAcrossEnvironments);
+ const activeEnvironmentId = useStore((state) => state.activeEnvironmentId);
+ const primaryEnvironmentId = usePrimaryEnvironmentId();
+ const threadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder);
+
+ const generalChatsProjectRef = useMemo(
+ () =>
+ resolveGeneralChatsProjectRef({
+ generalChatsProject,
+ activeEnvironmentId,
+ primaryEnvironmentId,
+ }),
+ [activeEnvironmentId, generalChatsProject, primaryEnvironmentId],
+ );
+
+ const chats = useMemo(() => {
+ if (generalChatsProject === null) {
+ return [];
+ }
+ return sortThreads(
+ threads.filter(
+ (thread) =>
+ thread.archivedAt === null &&
+ thread.environmentId === generalChatsProject.environmentId &&
+ thread.projectId === generalChatsProject.id,
+ ),
+ threadSortOrder,
+ );
+ }, [generalChatsProject, threadSortOrder, threads]);
+
+ const startChat = () => {
+ if (generalChatsProjectRef) {
+ void startNewGeneralChatThread(handleNewThread, generalChatsProjectRef);
+ }
+ };
+
+ return (
+
+
+
Chats
+
+
+
+ Conversations that aren't tied to a project.
+
+
+ {chats.length === 0 ? (
+
+ No chats yet.
+
+ ) : (
+
+ {chats.map((thread) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index f5b6e01ae..75f1b23dc 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -5,7 +5,6 @@ import {
ChevronUpIcon,
CloudIcon,
FolderPlusIcon,
- MessageCirclePlusIcon,
MessagesSquareIcon,
PinIcon,
PinOffIcon,
@@ -46,7 +45,6 @@ import { CSS } from "@dnd-kit/utilities";
import {
type ContextMenuItem,
ProjectId,
- type ScopedProjectRef,
type ScopedThreadRef,
type SidebarProjectGroupingMode,
type ThreadEnvMode,
@@ -72,9 +70,7 @@ import { isElectron } from "../env";
import { APP_BASE_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding";
import { isTerminalFocused } from "../lib/terminalFocus";
import { cn, isMacPlatform, newCommandId } from "../lib/utils";
-import { resolveGeneralChatsProjectRef } from "../lib/generalChats";
import {
- selectGeneralChatsProjectAcrossEnvironments,
selectProjectByRef,
selectProjectsAcrossEnvironments,
selectSidebarThreadsForProjectRefs,
@@ -177,6 +173,11 @@ import {
} from "./Sidebar.logic";
import { OnDeckSection, type OnDeckEntry } from "./sidebar/OnDeckSection";
import { DeckRail } from "./sidebar/DeckRail";
+import {
+ DESTINATION_ICONS,
+ DestinationBand,
+ type SidebarDestination,
+} from "./sidebar/DestinationBand";
import { startNewGeneralChatThread, startNewThreadFromContext } from "../lib/chatThreadActions";
import { sortThreads } from "../lib/threadSort";
import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
@@ -2789,9 +2790,8 @@ interface SidebarProjectsContentProps {
openAddProject: () => void;
/** The On Deck working-set group, rendered between search and the tree. */
onDeckSection: React.ReactNode;
- generalChatsProjectRef: ScopedProjectRef | null;
+ destinationBand: React.ReactNode;
/** Pinned above the Projects section; null while there are no general chats. */
- generalChatsProject: SidebarProjectSnapshot | null;
isManualProjectSorting: boolean;
projectDnDSensors: ReturnType;
projectCollisionDetection: CollisionDetection;
@@ -2831,8 +2831,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
updateSettings,
openAddProject,
onDeckSection,
- generalChatsProjectRef,
- generalChatsProject,
+ destinationBand,
isManualProjectSorting,
projectDnDSensors,
projectCollisionDetection,
@@ -2912,59 +2911,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
+ {destinationBand}
{onDeckSection}
- {/* The slot below search is always the General Chats home: a quiet
- new-chat action row until the first chat exists, then the group
- itself (whose hover button takes over starting new chats). */}
- {!generalChatsProject && generalChatsProjectRef ? (
-
-
-
- {
- void startNewGeneralChatThread(handleNewThread, generalChatsProjectRef);
- }}
- >
-
- New general chat
-
-
-
-
- ) : null}
- {generalChatsProject ? (
-
-
-
-
-
- ) : null}
+ On Deck changes height as threads come and go, so anything below it moves. Fixed navigation
+ has to sit above it. Real widths (218px pane, 44px rail) and real row heights, so the vertical
+ proportions below are honest.
+
+
+
+
+
+
Today
+
+ General Chats sits directly under On Deck, the one slot that moves every time a thread
+ joins or leaves the deck.
+
+
+
Search⌘K
+
On deck
+
+ Preview multi-queuebadcode
+
+
+ Preview 3-option…badcode
+
+
+ Deck sidebarbadcode
+
+
+
+ General Chats3
+
+
+ Remember the codeword
+
+
+ Reply with the single…
+
+
+
Projects
+
+ badcode12
+
+
+ onetradebot4
+
+
+ facpmanuals-next2
+
+
+
+
+
no band · deck rows start at 38px
+
+
+
+
+
Now · option 2
+
+ Only "New chat" is promoted. The General Chats group keeps its threads inline until a list
+ view exists. Band costs one row.
+
+
+
Search⌘K
+
+
+
+ New chat
+
+
+
On deck
+
+ Preview multi-queuebadcode
+
+
+ Preview 3-option…badcode
+
+
+ Deck sidebarbadcode
+
+
+
+ General Chats3
+
+
+ Remember the codeword
+
+
+
Projects
+
+ badcode12
+
+
+ onetradebot4
+
+
+ facpmanuals-next2
+
+
+
+
+
band costs 58px · deck rows start at 96px
+
+
+
+
+
Later · option 1
+
+ Chats becomes a destination alongside Pull requests, Scheduled and Plugins, each opening
+ its own list view. Four rows of chrome.
+
+
+
Search⌘K
+
+
+
+ New chat
+
+
+
+ Pull requests
+
+
+
+ Scheduled
+
+
+
+ Plugins
+
+
+
+ Chats
+
+
+
On deck
+
+ Preview multi-queuebadcode
+
+
+ Preview 3-option…badcode
+
+
+ Deck sidebarbadcode
+
+
+
Projects
+
+ badcode12
+
+
+ onetradebot4
+
+
+ facpmanuals-next2
+
+
+
+
+
band costs 171px · deck rows start at 209px
+
+
+
+
+
Rail · later
+
+ The band is what the rail was missing: destination icons, hairline, deck dots, settings.
+ Both densities say the same thing.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
44px wide · deck dots start at 254px
+
+
+
+
From 825a026c2359faf6178d5be70ad9000763dcec7e Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 02:12:24 -0400
Subject: [PATCH 09/82] Restore truncated thread detail with a hover card
A deck row shortens the title to fit 218px and the rail keeps nothing
but a status dot, so the sidebar's most compressed surfaces were also
the ones that said least about what they pointed at. Hovering a thread
now restores exactly what the row had to drop: the full title, its
status and last activity, the project it belongs to, its branch or
worktree, the machine when the thread is remote, and the model.
Built on Base UI's preview card rather than the tooltip, because a
tooltip is meant to be a short string and this is a small surface with
structure. Its open delay is longer than a tooltip's so the card does
not fire while the pointer is only crossing the rail on its way
somewhere else.
Deck rows also gained the project favicon inline. The project name was
already there, but as mono text it read as metadata rather than as the
project, which made a cross-project deck harder to scan than it should
have been.
The model is only known once a thread's shell has loaded, so the card
reports the provider until then, and it reports it the way the model
picker does -- "Claude", never the internal "claudeAgent".
---
.../src/components/ChatsDestinationView.tsx | 48 +++---
apps/web/src/components/Sidebar.tsx | 1 +
.../components/sidebar/DeckRail.browser.tsx | 17 +-
apps/web/src/components/sidebar/DeckRail.tsx | 48 +++---
.../src/components/sidebar/OnDeckSection.tsx | 157 ++++++++++--------
.../sidebar/ThreadHoverCard.browser.tsx | 88 ++++++++++
.../components/sidebar/ThreadHoverCard.tsx | 133 +++++++++++++++
apps/web/src/components/ui/preview-card.tsx | 62 +++++++
8 files changed, 431 insertions(+), 123 deletions(-)
create mode 100644 apps/web/src/components/sidebar/ThreadHoverCard.browser.tsx
create mode 100644 apps/web/src/components/sidebar/ThreadHoverCard.tsx
create mode 100644 apps/web/src/components/ui/preview-card.tsx
diff --git a/apps/web/src/components/ChatsDestinationView.tsx b/apps/web/src/components/ChatsDestinationView.tsx
index 55d188e64..92a2cf503 100644
--- a/apps/web/src/components/ChatsDestinationView.tsx
+++ b/apps/web/src/components/ChatsDestinationView.tsx
@@ -18,6 +18,8 @@ import {
import { buildThreadRouteParams } from "../threadRoutes";
import { formatRelativeTimeLabel } from "../timestampFormat";
import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators";
+import { resolveThreadStatusPill } from "./Sidebar.logic";
+import { ThreadHoverCard } from "./sidebar/ThreadHoverCard";
/**
* The Chats destination: general chats are threads with no project, so they get
@@ -92,28 +94,34 @@ export function ChatsDestinationView() {
) : (
{chats.map((thread) => (
-
+
+
))}
)}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 75f1b23dc..777a9f1cf 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -3412,6 +3412,7 @@ export default function Sidebar() {
thread,
status,
projectLabel: project?.kind === "general-chat" ? "chats" : (project?.displayName ?? null),
+ projectCwd: project?.kind === "general-chat" ? null : (project?.cwd ?? null),
dismissible: isOnDeckDismissible(status),
},
];
diff --git a/apps/web/src/components/sidebar/DeckRail.browser.tsx b/apps/web/src/components/sidebar/DeckRail.browser.tsx
index b0155ece1..7607fae30 100644
--- a/apps/web/src/components/sidebar/DeckRail.browser.tsx
+++ b/apps/web/src/components/sidebar/DeckRail.browser.tsx
@@ -42,7 +42,13 @@ function entry(
pill: ThreadStatusPill | null,
projectLabel: string | null = "badcode",
): OnDeckEntry {
- return { thread: thread(id, title), status: pill, projectLabel, dismissible: false };
+ return {
+ thread: thread(id, title),
+ status: pill,
+ projectLabel,
+ projectCwd: "/repo/badcode",
+ dismissible: false,
+ };
}
function renderRail(entries: readonly OnDeckEntry[], overrides?: { routeThreadKey?: string }) {
@@ -88,12 +94,17 @@ describe("DeckRail", () => {
);
});
- it("names the thread in place of the title the rail dropped", async () => {
+ it("labels the dot with the thread title and details it on hover", async () => {
renderRail([entry("thread-a", "Fix reconnect race", status("Awaiting Input"))]);
+ // The dot itself carries only the title; project, status and branch are
+ // the hover card's job now.
await expect
.element(page.getByTestId("deck-rail-thread-thread-a"))
- .toHaveAttribute("aria-label", "Fix reconnect race · badcode · Awaiting Input");
+ .toHaveAttribute("aria-label", "Fix reconnect race");
+
+ await page.getByTestId("deck-rail-thread-thread-a").hover();
+ await expect.element(page.getByTestId("thread-hover-card")).toHaveTextContent("Awaiting Input");
});
it("badges how many threads are blocked on the user and expands the pane", async () => {
diff --git a/apps/web/src/components/sidebar/DeckRail.tsx b/apps/web/src/components/sidebar/DeckRail.tsx
index 363ad1db0..4db66236c 100644
--- a/apps/web/src/components/sidebar/DeckRail.tsx
+++ b/apps/web/src/components/sidebar/DeckRail.tsx
@@ -8,6 +8,7 @@ import { ThreadStatusDot } from "../ThreadStatusIndicators";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import type { OnDeckEntry } from "./OnDeckSection";
import type { SidebarDestination } from "./DestinationBand";
+import { ThreadHoverCard } from "./ThreadHoverCard";
const RAIL_BUTTON_CLASS_NAME =
"flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground/70 outline-hidden ring-ring transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-2";
@@ -169,35 +170,24 @@ function DeckRailThread(props: {
navigateToThread(threadRef);
}, [navigateToThread, threadRef]);
- // The tooltip names the thread because the rail has dropped the title; the
- // status is appended so the dot's colour never has to be decoded on its own.
- const tooltip = [entry.thread.title, entry.projectLabel, entry.status ? entry.status.label : null]
- .filter((part): part is string => part !== null && part.length > 0)
- .join(" · ");
-
return (
-
-
-
-
- }
- />
- {tooltip}
-
+
+
+
);
}
diff --git a/apps/web/src/components/sidebar/OnDeckSection.tsx b/apps/web/src/components/sidebar/OnDeckSection.tsx
index 6bd0a2600..086eca4ec 100644
--- a/apps/web/src/components/sidebar/OnDeckSection.tsx
+++ b/apps/web/src/components/sidebar/OnDeckSection.tsx
@@ -7,6 +7,8 @@ import { cn } from "../../lib/utils";
import { resolveThreadRowClassName, type ThreadStatusPill } from "../Sidebar.logic";
import { ThreadStatusLabel } from "../ThreadStatusIndicators";
import { SectionLabel } from "../ui/threadline";
+import { ProjectFavicon } from "../ProjectFavicon";
+import { ThreadHoverCard } from "./ThreadHoverCard";
import { SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
@@ -14,6 +16,8 @@ export interface OnDeckEntry {
thread: SidebarThreadSummary;
status: ThreadStatusPill | null;
projectLabel: string | null;
+ /** Drives the row's project favicon; null for projects with no checkout. */
+ projectCwd: string | null;
dismissible: boolean;
}
@@ -29,7 +33,7 @@ const ON_DECK_ROW_BUTTON_RENDER = ;
const OnDeckRow = memo(function OnDeckRow(props: OnDeckRowProps) {
const { entry, isActive, jumpLabel, navigateToThread, dismissFromOnDeck } = props;
- const { thread, status, projectLabel, dismissible } = entry;
+ const { thread, status, projectLabel, projectCwd, dismissible } = entry;
const threadRef = scopeThreadRef(thread.environmentId, thread.id);
const threadKey = scopedThreadKey(threadRef);
@@ -58,81 +62,92 @@ const OnDeckRow = memo(function OnDeckRow(props: OnDeckRowProps) {
return (
-
-
- {status ? (
-
- ) : (
-
+
+
- {thread.pinnedAt !== null && (
-
-
-
- )}
-
- {thread.title}
-
-
- {jumpLabel ? (
-
- {jumpLabel}
-
- ) : null}
- {projectLabel ? (
+
+ {status ? (
+
+ ) : (
+
+ )}
+
+ {thread.pinnedAt !== null && (
- {projectLabel}
+
- ) : null}
-
- {dismissible && (
-
-
-
-
- }
- />
- Remove from On Deck
-
- )}
-
+ )}
+
+ {thread.title}
+
+
+ {jumpLabel ? (
+
+ {jumpLabel}
+
+ ) : null}
+ {projectLabel ? (
+
+ {projectCwd ? (
+
+ ) : null}
+
+ {projectLabel}
+
+
+ ) : null}
+
+ {dismissible && (
+
+
+
+
+ }
+ />
+ Remove from On Deck
+
+ )}
+
+
);
});
diff --git a/apps/web/src/components/sidebar/ThreadHoverCard.browser.tsx b/apps/web/src/components/sidebar/ThreadHoverCard.browser.tsx
new file mode 100644
index 000000000..e792097d4
--- /dev/null
+++ b/apps/web/src/components/sidebar/ThreadHoverCard.browser.tsx
@@ -0,0 +1,88 @@
+import { EnvironmentId, ProjectId, ProviderDriverKind, ThreadId } from "@threadlines/contracts";
+import { page } from "vite-plus/test/browser";
+import { describe, expect, it } from "vite-plus/test";
+import { render } from "vitest-browser-react";
+
+import type { ThreadStatusPill } from "../Sidebar.logic";
+import type { SidebarThreadSummary } from "../../types";
+import { ThreadHoverCard } from "./ThreadHoverCard";
+
+const ENVIRONMENT_ID = EnvironmentId.make("environment-local");
+
+function thread(overrides: Partial = {}): SidebarThreadSummary {
+ return {
+ id: ThreadId.make("thread-hover"),
+ environmentId: ENVIRONMENT_ID,
+ projectId: ProjectId.make("project-badcode"),
+ title: "Add smooth hover cards to the sidebar rows",
+ interactionMode: "default",
+ session: null,
+ createdAt: "2026-07-23T00:00:00.000Z",
+ archivedAt: null,
+ pinnedAt: null,
+ latestTurn: null,
+ branch: null,
+ worktreePath: null,
+ latestUserMessageAt: null,
+ hasPendingApprovals: false,
+ hasPendingUserInput: false,
+ hasActionableProposedPlan: false,
+ ...overrides,
+ };
+}
+
+function status(label: ThreadStatusPill["label"]): ThreadStatusPill {
+ return { label, colorClass: "", dotClass: "bg-primary-graph", pulse: false };
+}
+
+async function openCard(summary: SidebarThreadSummary, pill: ThreadStatusPill | null) {
+ render(
+
+
+ ,
+ );
+ await page.getByTestId("hover-trigger").hover();
+ return page.getByTestId("thread-hover-card");
+}
+
+describe("ThreadHoverCard", () => {
+ it("restores the full title the row had to truncate", async () => {
+ const card = await openCard(thread(), status("Working"));
+
+ await expect.element(card).toHaveTextContent("Add smooth hover cards to the sidebar rows");
+ await expect.element(card).toHaveTextContent("Working");
+ });
+
+ it("names the provider the way the model picker does", async () => {
+ const card = await openCard(
+ thread({
+ session: {
+ provider: ProviderDriverKind.make("claudeAgent"),
+ status: "ready",
+ orchestrationStatus: "idle",
+ createdAt: "2026-07-23T00:00:00.000Z",
+ updatedAt: "2026-07-23T00:00:00.000Z",
+ },
+ }),
+ null,
+ );
+
+ await expect.element(card).toHaveTextContent("Claude");
+ // The internal driver kind must never reach the surface.
+ await expect.element(card).not.toHaveTextContent("claudeAgent");
+ });
+
+ it("reports a branch when the thread has one", async () => {
+ const card = await openCard(thread({ branch: "feature/deck-sidebar" }), null);
+
+ await expect.element(card).toHaveTextContent("feature/deck-sidebar");
+ });
+
+ it("falls back to Idle when the thread has no status", async () => {
+ const card = await openCard(thread(), null);
+
+ await expect.element(card).toHaveTextContent("Idle");
+ });
+});
diff --git a/apps/web/src/components/sidebar/ThreadHoverCard.tsx b/apps/web/src/components/sidebar/ThreadHoverCard.tsx
new file mode 100644
index 000000000..c0ae2eb4b
--- /dev/null
+++ b/apps/web/src/components/sidebar/ThreadHoverCard.tsx
@@ -0,0 +1,133 @@
+import { scopeProjectRef, scopeThreadRef } from "@threadlines/client-runtime";
+import { GitBranchIcon, ServerIcon } from "lucide-react";
+import { useMemo, type ReactNode } from "react";
+
+import { PROVIDER_ICON_BY_PROVIDER } from "../chat/providerIconUtils";
+import { PROVIDER_OPTIONS } from "../../session-logic";
+import {
+ useSavedEnvironmentRegistryStore,
+ useSavedEnvironmentRuntimeStore,
+} from "../../environments/runtime";
+import { usePrimaryEnvironmentId } from "../../environments/primary";
+import { selectProjectByRef, useStore } from "../../store";
+import { createThreadSelectorByRef } from "../../storeSelectors";
+import { formatRelativeTimeLabel } from "../../timestampFormat";
+import type { SidebarThreadSummary } from "../../types";
+import type { ThreadStatusPill } from "../Sidebar.logic";
+import { ProjectFavicon } from "../ProjectFavicon";
+import { ThreadStatusDot } from "../ThreadStatusIndicators";
+import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card";
+
+function DetailRow({ icon, children }: { icon: ReactNode; children: ReactNode }) {
+ return (
+
+
+ {icon}
+
+ {children}
+
+ );
+}
+
+/**
+ * The details a truncated row had to drop.
+ *
+ * Deck rows shorten the title and the rail keeps only a status dot, so the
+ * thread's identity — which project, which branch, which machine — is exactly
+ * what a hover has to restore. Everything here is derived from data the
+ * sidebar already holds, except the model, which is only known once the
+ * thread's shell has loaded and is otherwise reported as the provider.
+ */
+export function ThreadHoverCard({
+ thread,
+ status,
+ side = "right",
+ children,
+}: {
+ thread: SidebarThreadSummary;
+ status: ThreadStatusPill | null;
+ side?: "right" | "left" | "top" | "bottom";
+ children: ReactNode;
+}) {
+ const projectRef = useMemo(
+ () => scopeProjectRef(thread.environmentId, thread.projectId),
+ [thread.environmentId, thread.projectId],
+ );
+ const project = useStore((state) => selectProjectByRef(state, projectRef));
+ const threadRef = useMemo(
+ () => scopeThreadRef(thread.environmentId, thread.id),
+ [thread.environmentId, thread.id],
+ );
+ const loadedThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]));
+
+ const primaryEnvironmentId = usePrimaryEnvironmentId();
+ const isRemote = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;
+ const runtimeLabel = useSavedEnvironmentRuntimeStore(
+ (state) => state.byId[thread.environmentId]?.descriptor?.label ?? null,
+ );
+ const savedLabel = useSavedEnvironmentRegistryStore(
+ (state) => state.byId[thread.environmentId]?.label ?? null,
+ );
+ const environmentLabel = isRemote ? (runtimeLabel ?? savedLabel ?? "Remote") : null;
+
+ const provider = thread.session?.provider ?? null;
+ const ProviderIcon = provider ? (PROVIDER_ICON_BY_PROVIDER[provider] ?? null) : null;
+ // The driver kind is an internal identifier ("claudeAgent"); show what the
+ // model picker shows. The model itself is only known once the thread's shell
+ // has loaded, so the provider is the fallback rather than the default.
+ const providerLabel = provider
+ ? (PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider)
+ : null;
+ const modelLabel = loadedThread?.modelSelection?.model ?? null;
+ const activityAt = thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt;
+ // A worktree is the more specific fact when present: it implies the branch
+ // and tells you the checkout is isolated.
+ const worktreeName = thread.worktreePath ? thread.worktreePath.split("/").pop() : null;
+
+ return (
+
+ {/* Quick enough to feel like a tooltip, slow enough not to fire while
+ the pointer is only travelling across the rail. */}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/ui/preview-card.tsx b/apps/web/src/components/ui/preview-card.tsx
new file mode 100644
index 000000000..94c85fa12
--- /dev/null
+++ b/apps/web/src/components/ui/preview-card.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
+
+import { cn } from "~/lib/utils";
+
+const PreviewCard = PreviewCardPrimitive.Root;
+
+function PreviewCardTrigger(props: PreviewCardPrimitive.Trigger.Props) {
+ return ;
+}
+
+/**
+ * Hover surface for rows that had to truncate. Styled off the popover so a
+ * preview reads as the same class of surface, with the scale/opacity entrance
+ * Base UI drives from `data-starting-style` / `data-ending-style`.
+ */
+function PreviewCardPopup({
+ children,
+ className,
+ positionerClassName,
+ side = "right",
+ align = "start",
+ sideOffset = 8,
+ ...props
+}: PreviewCardPrimitive.Popup.Props & {
+ side?: PreviewCardPrimitive.Positioner.Props["side"];
+ align?: PreviewCardPrimitive.Positioner.Props["align"];
+ sideOffset?: PreviewCardPrimitive.Positioner.Props["sideOffset"];
+ positionerClassName?: string;
+}) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+export { PreviewCard, PreviewCardPopup, PreviewCardTrigger };
From abe82c27ada6a9529ba62591935b069ab5ba813f Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 02:25:17 -0400
Subject: [PATCH 10/82] Group hover cards and close off the destination band
Two fixes to how the sidebar's top reads and behaves.
The destination band had no boundary of its own. Every other block in
the sidebar opens with a section label, so a lone row sitting in the gap
between search and the deck read as something that had lost its
section rather than as navigation. Measuring showed the rows were
already aligned to the same grid, so the fix is the missing boundary,
not indentation: the band now sits tight under search and a rule closes
it, which groups the two as the top chrome they are.
Hover cards now share a delay group per list. The delay exists to stop a
card firing while the pointer is only crossing the list on its way
elsewhere; once one card is open that intent is no longer in doubt, so
re-imposing the wait on the next row just punishes deliberate scanning.
Neighbouring rows now swap instantly and the group stays warm briefly
after the last card closes. Measured: 346ms for the first card, 2ms for
the next.
That grouping is why these are tooltips rather than preview cards --
only the tooltip primitive ships the shared-delay provider. The trigger
keeps an aria-label carrying the thread title, so the accessible name
stays complete even though the card's structure is exposed as a
tooltip's description.
---
.../src/components/ChatsDestinationView.tsx | 68 ++++++++++---------
apps/web/src/components/Sidebar.tsx | 1 +
apps/web/src/components/sidebar/DeckRail.tsx | 24 ++++---
.../components/sidebar/DestinationBand.tsx | 4 +-
.../src/components/sidebar/OnDeckSection.tsx | 38 ++++++-----
.../components/sidebar/ThreadHoverCard.tsx | 36 +++++++---
apps/web/src/components/ui/preview-card.tsx | 62 -----------------
7 files changed, 100 insertions(+), 133 deletions(-)
delete mode 100644 apps/web/src/components/ui/preview-card.tsx
diff --git a/apps/web/src/components/ChatsDestinationView.tsx b/apps/web/src/components/ChatsDestinationView.tsx
index 92a2cf503..7fba1d2ba 100644
--- a/apps/web/src/components/ChatsDestinationView.tsx
+++ b/apps/web/src/components/ChatsDestinationView.tsx
@@ -19,7 +19,7 @@ import { buildThreadRouteParams } from "../threadRoutes";
import { formatRelativeTimeLabel } from "../timestampFormat";
import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators";
import { resolveThreadStatusPill } from "./Sidebar.logic";
-import { ThreadHoverCard } from "./sidebar/ThreadHoverCard";
+import { ThreadHoverCard, ThreadHoverCardGroup } from "./sidebar/ThreadHoverCard";
/**
* The Chats destination: general chats are threads with no project, so they get
@@ -92,38 +92,42 @@ export function ChatsDestinationView() {
No chats yet.
) : (
-
-
- {entries.map((entry) => {
- const threadKey = scopedThreadKey(
- scopeThreadRef(entry.thread.environmentId, entry.thread.id),
- );
- return (
-
- );
- })}
-
+
+
+ {entries.map((entry) => {
+ const threadKey = scopedThreadKey(
+ scopeThreadRef(entry.thread.environmentId, entry.thread.id),
+ );
+ return (
+
+ );
+ })}
+
+
);
});
diff --git a/apps/web/src/components/sidebar/ThreadHoverCard.tsx b/apps/web/src/components/sidebar/ThreadHoverCard.tsx
index c0ae2eb4b..ed9e27ff4 100644
--- a/apps/web/src/components/sidebar/ThreadHoverCard.tsx
+++ b/apps/web/src/components/sidebar/ThreadHoverCard.tsx
@@ -16,7 +16,7 @@ import type { SidebarThreadSummary } from "../../types";
import type { ThreadStatusPill } from "../Sidebar.logic";
import { ProjectFavicon } from "../ProjectFavicon";
import { ThreadStatusDot } from "../ThreadStatusIndicators";
-import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card";
+import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
function DetailRow({ icon, children }: { icon: ReactNode; children: ReactNode }) {
return (
@@ -85,11 +85,15 @@ export function ThreadHoverCard({
const worktreeName = thread.worktreePath ? thread.worktreePath.split("/").pop() : null;
return (
-
- {/* Quick enough to feel like a tooltip, slow enough not to fire while
- the pointer is only travelling across the rail. */}
-
-
+
+
+
{thread.title}
@@ -127,7 +131,23 @@ export function ThreadHoverCard({
) : null}
-
-
+
+
+ );
+}
+
+/**
+ * Groups thread hover cards so a list behaves like one surface.
+ *
+ * The delay is there to stop a card firing while the pointer is only crossing
+ * the list on its way elsewhere. Once one card is open that intent is no longer
+ * in doubt, so neighbouring rows swap instantly, and `timeout` keeps the group
+ * warm briefly after the last one closes.
+ */
+export function ThreadHoverCardGroup({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
);
}
diff --git a/apps/web/src/components/ui/preview-card.tsx b/apps/web/src/components/ui/preview-card.tsx
deleted file mode 100644
index 94c85fa12..000000000
--- a/apps/web/src/components/ui/preview-card.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-"use client";
-
-import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
-
-import { cn } from "~/lib/utils";
-
-const PreviewCard = PreviewCardPrimitive.Root;
-
-function PreviewCardTrigger(props: PreviewCardPrimitive.Trigger.Props) {
- return ;
-}
-
-/**
- * Hover surface for rows that had to truncate. Styled off the popover so a
- * preview reads as the same class of surface, with the scale/opacity entrance
- * Base UI drives from `data-starting-style` / `data-ending-style`.
- */
-function PreviewCardPopup({
- children,
- className,
- positionerClassName,
- side = "right",
- align = "start",
- sideOffset = 8,
- ...props
-}: PreviewCardPrimitive.Popup.Props & {
- side?: PreviewCardPrimitive.Positioner.Props["side"];
- align?: PreviewCardPrimitive.Positioner.Props["align"];
- sideOffset?: PreviewCardPrimitive.Positioner.Props["sideOffset"];
- positionerClassName?: string;
-}) {
- return (
-
-
-
- {children}
-
-
-
- );
-}
-
-export { PreviewCard, PreviewCardPopup, PreviewCardTrigger };
From 4d382bde222f1ca2ac9c082c7adcc86b5ef4a73b Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 02:33:00 -0400
Subject: [PATCH 11/82] Keep general chats off the deck and settle the band's
spacing
On Deck is the working set for project work. A general chat belongs to
no project and answers no question about a repo, so it has no business
sitting between two threads that do -- it made the deck look like a
mixed inbox rather than the small stable set it is meant to be. The
deck sync now only ever sees project threads, and because the sync
drops keys absent from its input, a chat already on the deck evicts
itself without a migration.
Also two adjustments from looking at it in use. The hover card's title
was a size larger than everything below it and dominated a card whose
job is to be scanned; the hierarchy now comes from weight and colour at
a single size. And the destination band, having been given a rule last
round, was still pinned against the search field with no air on either
side -- it now has padding of its own so the rule reads as the edge of
a band rather than a line drawn under a cramped row.
---
apps/web/src/components/Sidebar.tsx | 28 +++++++++++++++++--
.../components/sidebar/DestinationBand.tsx | 2 +-
.../components/sidebar/ThreadHoverCard.tsx | 2 +-
3 files changed, 27 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 0ffb84127..f18ccbf9f 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -2912,7 +2912,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
{destinationBand}
-
+
{onDeckSection}
@@ -3321,6 +3321,28 @@ export default function Sidebar() {
() => sidebarThreads.filter((thread) => thread.archivedAt === null),
[sidebarThreads],
);
+ const generalChatProjectKeys = useMemo(
+ () =>
+ new Set(
+ projects
+ .filter((project) => project.kind === "general-chat")
+ .map((project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id))),
+ ),
+ [projects],
+ );
+ // On Deck is the working set for project work. General chats are
+ // conversations that belong to no project, so they never join the deck --
+ // they live behind the Chats destination and nowhere else.
+ const deckEligibleThreads = useMemo(
+ () =>
+ visibleThreads.filter(
+ (thread) =>
+ !generalChatProjectKeys.has(
+ scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
+ ),
+ ),
+ [generalChatProjectKeys, visibleThreads],
+ );
const sortedProjects = useMemo(() => {
const sortableProjects = sidebarProjects.map((project) => ({
...project,
@@ -3384,7 +3406,7 @@ export default function Sidebar() {
useEffect(() => {
syncOnDeck(
- visibleThreads.map((thread) => {
+ deckEligibleThreads.map((thread) => {
const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
return buildOnDeckSyncInput({
threadKey,
@@ -3394,7 +3416,7 @@ export default function Sidebar() {
}),
routeThreadKey,
);
- }, [onDeckStatusByThreadKey, routeThreadKey, syncOnDeck, visibleThreads]);
+ }, [deckEligibleThreads, onDeckStatusByThreadKey, routeThreadKey, syncOnDeck]);
const onDeckEntries = useMemo(() => {
return onDeckThreadKeys.flatMap((threadKey) => {
diff --git a/apps/web/src/components/sidebar/DestinationBand.tsx b/apps/web/src/components/sidebar/DestinationBand.tsx
index 6de5e96ba..361d417fe 100644
--- a/apps/web/src/components/sidebar/DestinationBand.tsx
+++ b/apps/web/src/components/sidebar/DestinationBand.tsx
@@ -26,7 +26,7 @@ export function DestinationBand({ destinations }: { destinations: readonly Sideb
}
return (
-
+
{destinations.map((destination) => {
const Icon = destination.icon;
diff --git a/apps/web/src/components/sidebar/ThreadHoverCard.tsx b/apps/web/src/components/sidebar/ThreadHoverCard.tsx
index ed9e27ff4..1144270ee 100644
--- a/apps/web/src/components/sidebar/ThreadHoverCard.tsx
+++ b/apps/web/src/components/sidebar/ThreadHoverCard.tsx
@@ -94,7 +94,7 @@ export function ThreadHoverCard({
className="w-64 rounded-lg p-3 text-left text-popover-foreground text-sm shadow-none elevate-popover"
data-testid="thread-hover-card"
>
-
+
{thread.title}
From c58eecb79bcd4bba9e1a971f5b30cd4536b0f6d3 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 02:40:25 -0400
Subject: [PATCH 12/82] Present chats as their own mode, not a project in
disguise
General chats are backed by a hidden project, and that implementation
detail was surfacing everywhere the user looks: the crumb read "General
Chats / New thread", a chat's hover card named the project as though it
had been chosen, the recent list under a chat advertised project
threads, and a terminal toggle offered to run commands in a checkout
that does not exist.
Chats now present as a mode. The crumb reads "Chats / New chat", the
hover card omits the backing project, recent lists no longer cross the
divide in either direction (a project draft stops advertising chats
too), and the terminal control is absent rather than disabled -- absent
because a chat has no checkout at all, where a project thread without a
checkout keeps the disabled toggle that explains itself.
Because chats no longer reach the deck, a running chat had nowhere left
to signal from, so the Chats destination carries the aggregate status of
the chats behind it. That is a badge on a band argued to hold none, but
the alternative was activity with no representation anywhere in the
sidebar, and the same "the control carries its own status" shape already
serves the branch toggle and the rail's attention badge.
---
apps/web/src/components/ChatView.tsx | 9 ++-
apps/web/src/components/RecentThreadsList.tsx | 39 +++++++++----
apps/web/src/components/Sidebar.tsx | 25 +++++++-
.../chat/ChatHeader.render.test.tsx | 1 +
apps/web/src/components/chat/ChatHeader.tsx | 58 +++++++++++--------
.../src/components/chat/DraftEmptyState.tsx | 1 +
.../components/sidebar/DestinationBand.tsx | 16 ++++-
.../components/sidebar/ThreadHoverCard.tsx | 5 +-
8 files changed, 114 insertions(+), 40 deletions(-)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 0d123a201..da5ab8b18 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -5992,8 +5992,12 @@ export default function ChatView(props: ChatViewProps) {
settings.sidebarThreadSortOrder);
const generalChatsProject = useStore(selectGeneralChatsProjectAcrossEnvironments);
- const recentThreads = useMemo(
- () =>
- sortThreads(
- threads.filter((thread) => thread.archivedAt === null),
- sidebarThreadSortOrder,
- ).slice(0, limit),
- [limit, sidebarThreadSortOrder, threads],
- );
+ const recentThreads = useMemo(() => {
+ const isGeneralChat = (thread: (typeof threads)[number]) =>
+ generalChatsProject !== null &&
+ thread.environmentId === generalChatsProject.environmentId &&
+ thread.projectId === generalChatsProject.id;
+ return sortThreads(
+ threads.filter((thread) => {
+ if (thread.archivedAt !== null) return false;
+ if (scope === "chats") return isGeneralChat(thread);
+ if (scope === "projects") return !isGeneralChat(thread);
+ return true;
+ }),
+ sidebarThreadSortOrder,
+ ).slice(0, limit);
+ }, [generalChatsProject, limit, scope, sidebarThreadSortOrder, threads]);
const projectByScopedKey = useMemo(
() =>
new Map(
@@ -55,7 +72,7 @@ export function RecentThreadsList({ limit = 3, testId, className }: RecentThread
return (
{recentThreads.map((thread) => {
@@ -83,7 +100,7 @@ export function RecentThreadsList({ limit = 3, testId, className }: RecentThread
{thread.title}
- {isGeneralChat ? (
+ {isGeneralChat && scope !== "chats" ? (
General
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index f18ccbf9f..974adfc48 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -3553,6 +3553,28 @@ export default function Sidebar() {
: EMPTY_THREAD_JUMP_LABELS;
// One list, rendered as rows in the pane and as icons in the rail, so both
// densities offer the same destinations in the same order.
+ const chatsStatus = useMemo(() => {
+ const chatThreads = visibleThreads.filter((thread) =>
+ generalChatProjectKeys.has(
+ scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
+ ),
+ );
+ if (chatThreads.length === 0) {
+ return null;
+ }
+ return resolveProjectStatusIndicator(
+ chatThreads.map((thread) => {
+ const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+ const lastVisitedAt = threadLastVisitedAtById[threadKey];
+ return resolveThreadStatusPill({
+ thread: {
+ ...thread,
+ ...(lastVisitedAt !== undefined ? { lastVisitedAt } : {}),
+ },
+ });
+ }),
+ );
+ }, [generalChatProjectKeys, threadLastVisitedAtById, visibleThreads]);
const destinations = useMemo(
() => [
{
@@ -3560,12 +3582,13 @@ export default function Sidebar() {
label: "Chats",
icon: DESTINATION_ICONS.chats,
active: pathname.startsWith("/chats"),
+ status: chatsStatus,
onSelect: () => {
void navigate({ to: "/chats" });
},
},
],
- [navigate, pathname],
+ [chatsStatus, navigate, pathname],
);
const destinationBand = useMemo(
() => ,
diff --git a/apps/web/src/components/chat/ChatHeader.render.test.tsx b/apps/web/src/components/chat/ChatHeader.render.test.tsx
index 2a4f725e5..12ff90ec8 100644
--- a/apps/web/src/components/chat/ChatHeader.render.test.tsx
+++ b/apps/web/src/components/chat/ChatHeader.render.test.tsx
@@ -21,6 +21,7 @@ function renderChatHeader(overrides: Partial>
keybindings: [],
availableEditors: [],
terminalAvailable: true,
+ terminalApplicable: true,
terminalOpen: false,
terminalToggleShortcutLabel: null,
sourceControlToggleShortcutLabel: null,
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx
index 10bdbabd0..700719442 100644
--- a/apps/web/src/components/chat/ChatHeader.tsx
+++ b/apps/web/src/components/chat/ChatHeader.tsx
@@ -43,6 +43,13 @@ interface ChatHeaderProps {
keybindings: ResolvedKeybindingsConfig;
availableEditors: ReadonlyArray;
terminalAvailable: boolean;
+ /**
+ * False where a terminal makes no sense at all rather than being temporarily
+ * unavailable. A thread without a project still shows a disabled toggle that
+ * explains itself; a general chat has no checkout to run anything in, so the
+ * control is absent instead.
+ */
+ terminalApplicable: boolean;
terminalOpen: boolean;
terminalToggleShortcutLabel: string | null;
sourceControlToggleShortcutLabel: string | null;
@@ -113,6 +120,7 @@ export const ChatHeader = memo(function ChatHeader({
keybindings,
availableEditors,
terminalAvailable,
+ terminalApplicable,
terminalOpen,
terminalToggleShortcutLabel,
sourceControlToggleShortcutLabel,
@@ -283,30 +291,32 @@ export const ChatHeader = memo(function ChatHeader({
) : null}
-
-
-
-
- }
- />
-
- {!terminalAvailable
- ? "Terminal is unavailable until this thread has an active project."
- : terminalToggleShortcutLabel
- ? `Toggle terminal drawer (${terminalToggleShortcutLabel})`
- : "Toggle terminal drawer"}
-
-
+ {terminalApplicable ? (
+
+
+
+
+ }
+ />
+
+ {!terminalAvailable
+ ? "Terminal is unavailable until this thread has an active project."
+ : terminalToggleShortcutLabel
+ ? `Toggle terminal drawer (${terminalToggleShortcutLabel})`
+ : "Toggle terminal drawer"}
+
+
+ ) : null}
{sourceControlAvailable || sourceControlOpen ? (
diff --git a/apps/web/src/components/sidebar/DestinationBand.tsx b/apps/web/src/components/sidebar/DestinationBand.tsx
index 361d417fe..418349b14 100644
--- a/apps/web/src/components/sidebar/DestinationBand.tsx
+++ b/apps/web/src/components/sidebar/DestinationBand.tsx
@@ -1,6 +1,8 @@
import { MessageCirclePlusIcon, MessagesSquareIcon, type LucideIcon } from "lucide-react";
import { cn } from "../../lib/utils";
+import type { ThreadStatusPill } from "../Sidebar.logic";
+import { ThreadStatusDot } from "../ThreadStatusIndicators";
import { SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
export interface SidebarDestination {
@@ -9,6 +11,12 @@ export interface SidebarDestination {
icon: LucideIcon;
active: boolean;
disabled?: boolean;
+ /**
+ * Activity behind the destination. Chats no longer appear on the deck, so
+ * without this a running chat would have no ambient signal anywhere. The
+ * destination carries its own status rather than earning a second row.
+ */
+ status?: ThreadStatusPill | null;
onSelect: () => void;
}
@@ -44,7 +52,13 @@ export function DestinationBand({ destinations }: { destinations: readonly Sideb
onClick={destination.onSelect}
>
- {destination.label}
+ {destination.label}
+ {destination.status ? (
+
+ ) : null}
);
diff --git a/apps/web/src/components/sidebar/ThreadHoverCard.tsx b/apps/web/src/components/sidebar/ThreadHoverCard.tsx
index 1144270ee..631c6b6d3 100644
--- a/apps/web/src/components/sidebar/ThreadHoverCard.tsx
+++ b/apps/web/src/components/sidebar/ThreadHoverCard.tsx
@@ -53,7 +53,10 @@ export function ThreadHoverCard({
() => scopeProjectRef(thread.environmentId, thread.projectId),
[thread.environmentId, thread.projectId],
);
- const project = useStore((state) => selectProjectByRef(state, projectRef));
+ const backingProject = useStore((state) => selectProjectByRef(state, projectRef));
+ // General chats are backed by a hidden project. Surfacing it here would put
+ // "General Chats" in front of the user as though they had chosen it.
+ const project = backingProject?.kind === "general-chat" ? undefined : backingProject;
const threadRef = useMemo(
() => scopeThreadRef(thread.environmentId, thread.id),
[thread.environmentId, thread.id],
From 7956f395f85f20784874979a57af10d5c80225c8 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 02:46:52 -0400
Subject: [PATCH 13/82] Restore the chat terminal and name the destination
"General chats"
Removing the terminal from general chats rested on a false premise. The
claim was that a chat has no checkout to run anything in, but general
chats run in a scratch workspace, so the terminal was working the whole
time and was useful there. It is back, and the prop added to gate it is
gone rather than left behind always true.
"Chats" also asked users to hold a distinction the word does not carry:
a thread inside a project is a conversation too, so a list called Chats
invites them to look for project work in it. "General chats" says the
thing that actually separates them -- these belong to no project -- and
it fits the sidebar without truncating.
---
apps/web/src/components/ChatView.tsx | 3 +-
.../src/components/ChatsDestinationView.tsx | 2 +-
apps/web/src/components/Sidebar.tsx | 2 +-
.../chat/ChatHeader.render.test.tsx | 1 -
apps/web/src/components/chat/ChatHeader.tsx | 58 ++++++++-----------
.../components/sidebar/DeckRail.browser.tsx | 4 +-
6 files changed, 29 insertions(+), 41 deletions(-)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index da5ab8b18..b4bcec26a 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -5997,7 +5997,7 @@ export default function ChatView(props: ChatViewProps) {
}
// "General Chats" is the backing project, not a place the user chose
// to be. Chats present as their own mode, so the crumb says so.
- activeProjectName={isGeneralChatThread ? "Chats" : activeProject?.name}
+ activeProjectName={isGeneralChatThread ? "General chats" : activeProject?.name}
isGitRepo={isGitRepo}
openInCwd={isGeneralChatThread ? null : gitCwd}
activeProjectScripts={isGeneralChatThread ? undefined : activeProject?.scripts}
@@ -6007,7 +6007,6 @@ export default function ChatView(props: ChatViewProps) {
keybindings={keybindings}
availableEditors={availableEditors}
terminalAvailable={activeProject !== undefined}
- terminalApplicable={!isGeneralChatThread}
terminalOpen={terminalState.terminalOpen}
terminalToggleShortcutLabel={terminalToggleShortcutLabel}
sourceControlToggleShortcutLabel={sourceControlPanelShortcutLabel}
diff --git a/apps/web/src/components/ChatsDestinationView.tsx b/apps/web/src/components/ChatsDestinationView.tsx
index 7fba1d2ba..61aab7631 100644
--- a/apps/web/src/components/ChatsDestinationView.tsx
+++ b/apps/web/src/components/ChatsDestinationView.tsx
@@ -71,7 +71,7 @@ export function ChatsDestinationView() {
data-testid="chats-view"
>
-
Chats
+
General chats
[
{
id: "chats",
- label: "Chats",
+ label: "General chats",
icon: DESTINATION_ICONS.chats,
active: pathname.startsWith("/chats"),
status: chatsStatus,
diff --git a/apps/web/src/components/chat/ChatHeader.render.test.tsx b/apps/web/src/components/chat/ChatHeader.render.test.tsx
index 12ff90ec8..2a4f725e5 100644
--- a/apps/web/src/components/chat/ChatHeader.render.test.tsx
+++ b/apps/web/src/components/chat/ChatHeader.render.test.tsx
@@ -21,7 +21,6 @@ function renderChatHeader(overrides: Partial>
keybindings: [],
availableEditors: [],
terminalAvailable: true,
- terminalApplicable: true,
terminalOpen: false,
terminalToggleShortcutLabel: null,
sourceControlToggleShortcutLabel: null,
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx
index 700719442..10bdbabd0 100644
--- a/apps/web/src/components/chat/ChatHeader.tsx
+++ b/apps/web/src/components/chat/ChatHeader.tsx
@@ -43,13 +43,6 @@ interface ChatHeaderProps {
keybindings: ResolvedKeybindingsConfig;
availableEditors: ReadonlyArray;
terminalAvailable: boolean;
- /**
- * False where a terminal makes no sense at all rather than being temporarily
- * unavailable. A thread without a project still shows a disabled toggle that
- * explains itself; a general chat has no checkout to run anything in, so the
- * control is absent instead.
- */
- terminalApplicable: boolean;
terminalOpen: boolean;
terminalToggleShortcutLabel: string | null;
sourceControlToggleShortcutLabel: string | null;
@@ -120,7 +113,6 @@ export const ChatHeader = memo(function ChatHeader({
keybindings,
availableEditors,
terminalAvailable,
- terminalApplicable,
terminalOpen,
terminalToggleShortcutLabel,
sourceControlToggleShortcutLabel,
@@ -291,32 +283,30 @@ export const ChatHeader = memo(function ChatHeader({
) : null}
- {terminalApplicable ? (
-
-
-
-
- }
- />
-
- {!terminalAvailable
- ? "Terminal is unavailable until this thread has an active project."
- : terminalToggleShortcutLabel
- ? `Toggle terminal drawer (${terminalToggleShortcutLabel})`
- : "Toggle terminal drawer"}
-
-
- ) : null}
+
+
+
+
+ }
+ />
+
+ {!terminalAvailable
+ ? "Terminal is unavailable until this thread has an active project."
+ : terminalToggleShortcutLabel
+ ? `Toggle terminal drawer (${terminalToggleShortcutLabel})`
+ : "Toggle terminal drawer"}
+
+
{sourceControlAvailable || sourceControlOpen ? (
{
const { onSelectChats } = renderRail([]);
const chats = page.getByTestId("deck-rail-destination-chats");
- await expect.element(chats).toHaveAttribute("aria-label", "Chats");
+ await expect.element(chats).toHaveAttribute("aria-label", "General chats");
await chats.click();
expect(onSelectChats).toHaveBeenCalledOnce();
From c814fcc60482445870e3d6761b25728e5a95554c Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 02:56:33 -0400
Subject: [PATCH 14/82] Show projects on the rail and keep the titlebar one
colour
The rail dropped the project tree entirely, so collapsing the sidebar
meant losing every signal about projects you were not currently inside.
Each project now keeps a glyph below the deck dots, carrying its
favicon and the same aggregate status its expanded row shows; clicking
one opens the pane with that project already unfolded rather than
navigating somewhere unasked.
That status was the reason the glyphs were cut in the first place: the
derivation lived inside the project row and duplicating it would have
let the two drift. It is now a shared helper both callers use, so the
glyph and the row can only ever agree.
The rail is also narrower than macOS traffic lights, so painting the
rail colour behind them split the titlebar into two greys that met
mid-button. The rail now starts below the titlebar in Electron, leaving
one continuous strip across the top. Scoped to the left sidebar: the
right panel is a collapsible container too and would otherwise have
picked up the same gradient.
---
apps/web/src/components/Sidebar.logic.ts | 26 ++++++
apps/web/src/components/Sidebar.tsx | 55 +++++++++----
.../components/sidebar/DeckRail.browser.tsx | 75 ++++++++++++-----
apps/web/src/components/sidebar/DeckRail.tsx | 80 ++++++++++++++++++-
apps/web/src/index.css | 13 +++
5 files changed, 211 insertions(+), 38 deletions(-)
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index c5b195e2f..89cff7cc4 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -495,6 +495,32 @@ export function countThreadsNeedingUser(statuses: ReadonlyArray string;
+ /** Lookup rather than a record so callers can pass a Map or an object. */
+ getLastVisitedAt: (threadKey: string) => string | null | undefined;
+}): ThreadStatusPill | null {
+ return resolveProjectStatusIndicator(
+ input.threads.map((thread) => {
+ const lastVisitedAt = input.getLastVisitedAt(input.getThreadKey(thread));
+ return resolveThreadStatusPill({
+ thread: {
+ ...thread,
+ ...(lastVisitedAt !== undefined && lastVisitedAt !== null ? { lastVisitedAt } : {}),
+ },
+ });
+ }),
+ );
+}
+
export function resolveProjectStatusIndicator(
statuses: ReadonlyArray,
): ThreadStatusPill | null {
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index ffdb215ee..2d6ed8ab0 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -160,6 +160,7 @@ import {
isOnDeckDismissible,
resolveAdjacentThreadId,
isContextMenuPointerDown,
+ resolveProjectStatusForThreads,
resolveProjectStatusIndicator,
resolveSidebarNewThreadSeedContext,
resolveSidebarNewThreadEnvMode,
@@ -172,7 +173,7 @@ import {
ThreadStatusPill,
} from "./Sidebar.logic";
import { OnDeckSection, type OnDeckEntry } from "./sidebar/OnDeckSection";
-import { DeckRail } from "./sidebar/DeckRail";
+import { DeckRail, type DeckRailProject } from "./sidebar/DeckRail";
import {
DESTINATION_ICONS,
DestinationBand,
@@ -1269,24 +1270,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
threadLastVisitedAts[index] ?? null,
]),
);
- const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => {
- const lastVisitedAt = lastVisitedAtByThreadKey.get(
- scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
- );
- return resolveThreadStatusPill({
- thread: {
- ...thread,
- ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}),
- },
- });
- };
const visibleProjectThreads = sortThreads(
projectThreads.filter((thread) => thread.archivedAt === null),
threadSortOrder,
);
- const projectStatus = resolveProjectStatusIndicator(
- visibleProjectThreads.map((thread) => resolveProjectThreadStatus(thread)),
- );
+ const projectStatus = resolveProjectStatusForThreads({
+ threads: visibleProjectThreads,
+ getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ getLastVisitedAt: (threadKey) => lastVisitedAtByThreadKey.get(threadKey),
+ });
return {
orderedProjectThreadKeys: visibleProjectThreads.map((thread) =>
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
@@ -3234,6 +3226,16 @@ export default function Sidebar() {
const expandSidebar = useCallback(() => {
void setOpen(true);
}, [setOpen]);
+ const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded);
+ // The rail has nowhere to show a thread list, so a glyph opens the pane with
+ // that project already unfolded rather than navigating somewhere unasked.
+ const revealProject = useCallback(
+ (projectKey: string) => {
+ setProjectExpanded(projectKey, true);
+ void setOpen(true);
+ },
+ [setOpen, setProjectExpanded],
+ );
const openSettings = useCallback(() => {
void navigate({ to: "/settings" });
}, [navigate]);
@@ -3418,6 +3420,27 @@ export default function Sidebar() {
);
}, [deckEligibleThreads, onDeckStatusByThreadKey, routeThreadKey, syncOnDeck]);
+ // The rail drops the project tree, so each project keeps a glyph carrying the
+ // same aggregate status its expanded row shows.
+ const railProjects = useMemo(
+ () =>
+ sortedProjects.map((project) => ({
+ projectKey: project.projectKey,
+ name: project.displayName,
+ cwd: project.cwd,
+ environmentId: project.environmentId,
+ status: resolveProjectStatusForThreads({
+ threads: (threadsByProjectKey.get(project.projectKey) ?? []).filter(
+ (thread) => thread.archivedAt === null,
+ ),
+ getThreadKey: (thread) =>
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
+ getLastVisitedAt: (threadKey) => threadLastVisitedAtById[threadKey],
+ }),
+ })),
+ [sortedProjects, threadLastVisitedAtById, threadsByProjectKey],
+ );
+
const onDeckEntries = useMemo(() => {
return onDeckThreadKeys.flatMap((threadKey) => {
const thread = sidebarThreadByKey.get(threadKey);
@@ -3773,6 +3796,8 @@ export default function Sidebar() {
,
+
+
+ ,
);
- return { expandSidebar, navigateToThread, onSelectChats, screen };
+ return { expandSidebar, navigateToThread, onRevealProject, onSelectChats, screen };
}
describe("DeckRail", () => {
@@ -132,6 +145,26 @@ describe("DeckRail", () => {
expect(onSelectChats).toHaveBeenCalledOnce();
});
+ it("shows a project glyph that opens the pane at that project", async () => {
+ const { onRevealProject } = renderRail([], {
+ projects: [
+ {
+ projectKey: "badcode",
+ name: "badcode",
+ cwd: "/repo/badcode",
+ environmentId: ENVIRONMENT_ID,
+ status: status("Working"),
+ },
+ ],
+ });
+
+ const glyph = page.getByTestId("deck-rail-project-badcode");
+ await expect.element(glyph).toHaveAttribute("aria-label", "badcode · Working");
+
+ await glyph.click();
+ expect(onRevealProject).toHaveBeenCalledWith("badcode");
+ });
+
it("omits the attention badge when nothing is waiting on the user", async () => {
renderRail([entry("thread-a", "Fix reconnect race", status("Working"))]);
diff --git a/apps/web/src/components/sidebar/DeckRail.tsx b/apps/web/src/components/sidebar/DeckRail.tsx
index 64546beac..77eeba8ff 100644
--- a/apps/web/src/components/sidebar/DeckRail.tsx
+++ b/apps/web/src/components/sidebar/DeckRail.tsx
@@ -1,10 +1,15 @@
import { BellDotIcon, SearchIcon, SettingsIcon, SquarePenIcon } from "lucide-react";
import { memo, useCallback } from "react";
-import type { ScopedThreadRef } from "@threadlines/contracts";
+import type { EnvironmentId, ScopedThreadRef } from "@threadlines/contracts";
import { scopedThreadKey, scopeThreadRef } from "@threadlines/client-runtime";
import { cn } from "../../lib/utils";
-import { countThreadsNeedingUser, isNeedsUserStatus } from "../Sidebar.logic";
+import {
+ countThreadsNeedingUser,
+ isNeedsUserStatus,
+ type ThreadStatusPill,
+} from "../Sidebar.logic";
import { ThreadStatusDot } from "../ThreadStatusIndicators";
+import { ProjectFavicon } from "../ProjectFavicon";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import type { OnDeckEntry } from "./OnDeckSection";
import type { SidebarDestination } from "./DestinationBand";
@@ -55,8 +60,18 @@ function RailButton({
);
}
+export interface DeckRailProject {
+ projectKey: string;
+ name: string;
+ cwd: string;
+ environmentId: EnvironmentId;
+ status: ThreadStatusPill | null;
+}
+
export interface DeckRailProps {
entries: readonly OnDeckEntry[];
+ projects: readonly DeckRailProject[];
+ onRevealProject: (projectKey: string) => void;
destinations: readonly SidebarDestination[];
routeThreadKey: string | null;
navigateToThread: (threadRef: ScopedThreadRef) => void;
@@ -77,6 +92,8 @@ export interface DeckRailProps {
export const DeckRail = memo(function DeckRail(props: DeckRailProps) {
const {
entries,
+ projects,
+ onRevealProject,
destinations,
routeThreadKey,
navigateToThread,
@@ -151,6 +168,21 @@ export const DeckRail = memo(function DeckRail(props: DeckRailProps) {
>
) : null}
+ {projects.length > 0 ? (
+ <>
+
+
+ {projects.map((project) => (
+
+ ))}
+
+ >
+ ) : null}
+
@@ -193,3 +225,47 @@ function DeckRailThread(props: {
);
}
+
+function DeckRailProjectGlyph({
+ project,
+ onRevealProject,
+}: {
+ project: DeckRailProject;
+ onRevealProject: (projectKey: string) => void;
+}) {
+ const handleClick = useCallback(() => {
+ onRevealProject(project.projectKey);
+ }, [onRevealProject, project.projectKey]);
+ const label = project.status ? `${project.name} · ${project.status.label}` : project.name;
+
+ return (
+
+
+
+ {project.status ? (
+ // Corner dot rather than a row of its own: the glyph already
+ // identifies the project, so activity only needs a mark.
+
+ ) : null}
+
+ }
+ />
+ {label}
+
+ );
+}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index b1b7eb103..cb49afba8 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -285,6 +285,19 @@ html.electron {
--app-window-resize-edge-inset: 6px;
}
+/* macOS traffic lights are wider than the collapsed rail, so painting the rail
+ colour behind them splits the titlebar into two greys that meet mid-button.
+ Start the rail below the titlebar instead, leaving one continuous strip
+ across the top. Only in Electron: nothing else draws window controls. */
+html.electron [data-collapsible="icon"][data-side="left"] [data-slot="sidebar-container"] {
+ background: linear-gradient(
+ to bottom,
+ transparent 0,
+ transparent var(--workspace-topbar-height),
+ var(--rail) var(--workspace-topbar-height)
+ );
+}
+
body {
font-family: var(--font-sans);
margin: 0;
From 0cc8f9f7df19f59371146854d3f0adb67260003d Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 03:08:35 -0400
Subject: [PATCH 15/82] Lift the rail off the window controls properly
The previous attempt at this only looked correct in the computed style.
The rail surface is painted by the sidebar's inner element, not the
container the cutout was applied to, so the inner element went on
covering the titlebar and the traffic lights kept sitting on rail grey.
The cutout now applies where the colour is actually drawn.
The rail's right border was the other half of it: a border spans the
element's full height, so the edge ran up through the titlebar and met
the yellow button. It is drawn as a background layer instead, starting
at the same offset as the surface, leaving the top band uninterrupted
across the whole window.
One residual: the band ends at the titlebar height while the chat
header's rule sits about three pixels higher, so the two horizontal
edges do not quite meet at the corner. Aligning them means keying off
--chat-header-bottom, which is measured at runtime and is zero until a
chat header mounts -- on a route without one the rail would paint to the
top again and bring the original problem back.
---
apps/web/src/index.css | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index cb49afba8..9f4d1ec7a 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -290,6 +290,22 @@ html.electron {
Start the rail below the titlebar instead, leaving one continuous strip
across the top. Only in Electron: nothing else draws window controls. */
html.electron [data-collapsible="icon"][data-side="left"] [data-slot="sidebar-container"] {
+ /* The right edge is drawn as a background layer rather than a border so it
+ can start below the titlebar too; a real border spans the full height and
+ would strike the window controls. */
+ border-right-color: transparent;
+ background: linear-gradient(
+ to bottom,
+ transparent 0,
+ transparent var(--workspace-topbar-height),
+ var(--border) var(--workspace-topbar-height)
+ )
+ right / 1px 100% no-repeat;
+}
+
+/* The rail surface itself lives on the inner element, which paints over the
+ container, so the titlebar cutout has to be applied here as well. */
+html.electron [data-collapsible="icon"][data-side="left"] [data-slot="sidebar-inner"] {
background: linear-gradient(
to bottom,
transparent 0,
From d298ec53381026f5a0fa53f2d1e360d498d53a4f Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 03:15:06 -0400
Subject: [PATCH 16/82] Give the chats list its own head and drop the rail's
false grip
Four fixes to the general chats surfaces.
The list read as a continuation of the page title: a heading, a
subtitle, a rule, then rows of nearly the same weight. A count label
now heads the list the way section labels do elsewhere, with room above
it, and the row titles sit a shade back from the title so the hierarchy
is legible rather than implied.
Starting a chat also meant opening the destination first, which project
rows never ask of you. The Chats row now carries the same
hover-revealed action its project siblings do; the status dot yields
that corner while the action is showing rather than the two stacking.
The collapsed rail advertised a resize it cannot perform -- the drag
handle was still mounted at a width the rail does not honour. It is
hidden while collapsed, so the only thing on that edge is the border.
That border is now macOS-only in its offset form. Only macOS draws
window controls over the app's top-left corner; Windows and Linux put
them on the right, so there the rail is free to run to the very top.
---
apps/web/src/components/AppSidebarLayout.tsx | 4 +-
.../src/components/ChatsDestinationView.tsx | 11 +++--
apps/web/src/components/Sidebar.tsx | 26 ++++++++++-
.../components/sidebar/DestinationBand.tsx | 46 +++++++++++++++++--
apps/web/src/index.css | 4 +-
apps/web/src/main.tsx | 6 +++
6 files changed, 87 insertions(+), 10 deletions(-)
diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx
index 2812a2bef..ce8a3b540 100644
--- a/apps/web/src/components/AppSidebarLayout.tsx
+++ b/apps/web/src/components/AppSidebarLayout.tsx
@@ -149,7 +149,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
}}
>
-
+ {/* Only the expanded pane resizes; a handle on the fixed-width rail
+ would advertise a drag that does nothing. */}
+
{children}
diff --git a/apps/web/src/components/ChatsDestinationView.tsx b/apps/web/src/components/ChatsDestinationView.tsx
index 61aab7631..d5d508c19 100644
--- a/apps/web/src/components/ChatsDestinationView.tsx
+++ b/apps/web/src/components/ChatsDestinationView.tsx
@@ -83,16 +83,21 @@ export function ChatsDestinationView() {
New chat
-
+
Conversations that aren't tied to a project.
{chats.length === 0 ? (
-
+
No chats yet.
) : (
+ {/* The rows repeat the page title's shape closely enough that a rule
+ alone read as more list. A label gives the list a head of its own. */}
+
{chats.map((thread) => (
-
+
{thread.title}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 2d6ed8ab0..fd4f0f7b0 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -66,12 +66,14 @@ import {
type SidebarThreadSortOrder,
} from "@threadlines/contracts/settings";
import { usePrimaryEnvironmentId } from "../environments/primary";
+import { resolveGeneralChatsProjectRef } from "../lib/generalChats";
import { isElectron } from "../env";
import { APP_BASE_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding";
import { isTerminalFocused } from "../lib/terminalFocus";
import { cn, isMacPlatform, newCommandId } from "../lib/utils";
import {
selectProjectByRef,
+ selectGeneralChatsProjectAcrossEnvironments,
selectProjectsAcrossEnvironments,
selectSidebarThreadsForProjectRefs,
selectSidebarThreadsAcrossEnvironments,
@@ -3031,6 +3033,19 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
export default function Sidebar() {
const projects = useStore(useShallow(selectProjectsAcrossEnvironments));
const primaryEnvironmentId = usePrimaryEnvironmentId();
+ const generalChatsProject = useStore(selectGeneralChatsProjectAcrossEnvironments);
+ const activeEnvironmentId = useStore((state) => state.activeEnvironmentId);
+ // Needed for the destination row's new-chat action: the helper falls back to
+ // the active environment when no general-chat project exists yet.
+ const generalChatsProjectRef = useMemo(
+ () =>
+ resolveGeneralChatsProjectRef({
+ generalChatsProject,
+ activeEnvironmentId,
+ primaryEnvironmentId,
+ }),
+ [activeEnvironmentId, generalChatsProject, primaryEnvironmentId],
+ );
const workspaceProjectsLength = projects.filter(
(project) => project.kind !== "general-chat",
).length;
@@ -3606,12 +3621,21 @@ export default function Sidebar() {
icon: DESTINATION_ICONS.chats,
active: pathname.startsWith("/chats"),
status: chatsStatus,
+ action: {
+ label: "New chat",
+ icon: DESTINATION_ICONS.newChat,
+ onSelect: () => {
+ if (generalChatsProjectRef) {
+ void startNewGeneralChatThread(handleNewThread, generalChatsProjectRef);
+ }
+ },
+ },
onSelect: () => {
void navigate({ to: "/chats" });
},
},
],
- [chatsStatus, navigate, pathname],
+ [chatsStatus, generalChatsProjectRef, handleNewThread, navigate, pathname],
);
const destinationBand = useMemo(
() => ,
diff --git a/apps/web/src/components/sidebar/DestinationBand.tsx b/apps/web/src/components/sidebar/DestinationBand.tsx
index 418349b14..f6073252d 100644
--- a/apps/web/src/components/sidebar/DestinationBand.tsx
+++ b/apps/web/src/components/sidebar/DestinationBand.tsx
@@ -3,7 +3,14 @@ import { MessageCirclePlusIcon, MessagesSquareIcon, type LucideIcon } from "luci
import { cn } from "../../lib/utils";
import type { ThreadStatusPill } from "../Sidebar.logic";
import { ThreadStatusDot } from "../ThreadStatusIndicators";
-import { SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
+import {
+ SidebarGroup,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from "../ui/sidebar";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
export interface SidebarDestination {
id: string;
@@ -17,6 +24,15 @@ export interface SidebarDestination {
* destination carries its own status rather than earning a second row.
*/
status?: ThreadStatusPill | null;
+ /**
+ * Optional trailing action, mirroring the project rows' new-thread button so
+ * the common action is reachable without first opening the destination.
+ */
+ action?: {
+ label: string;
+ icon: LucideIcon;
+ onSelect: () => void;
+ };
onSelect: () => void;
}
@@ -38,8 +54,9 @@ export function DestinationBand({ destinations }: { destinations: readonly Sideb
{destinations.map((destination) => {
const Icon = destination.icon;
+ const ActionIcon = destination.action?.icon;
return (
-
+
) : null}
+ {destination.action && ActionIcon ? (
+
+
+
+
+ }
+ />
+ {destination.action.label}
+
+ ) : null}
);
})}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 9f4d1ec7a..dac5bc484 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -289,7 +289,7 @@ html.electron {
colour behind them splits the titlebar into two greys that meet mid-button.
Start the rail below the titlebar instead, leaving one continuous strip
across the top. Only in Electron: nothing else draws window controls. */
-html.electron [data-collapsible="icon"][data-side="left"] [data-slot="sidebar-container"] {
+html.electron.mac [data-collapsible="icon"][data-side="left"] [data-slot="sidebar-container"] {
/* The right edge is drawn as a background layer rather than a border so it
can start below the titlebar too; a real border spans the full height and
would strike the window controls. */
@@ -305,7 +305,7 @@ html.electron [data-collapsible="icon"][data-side="left"] [data-slot="sidebar-co
/* The rail surface itself lives on the inner element, which paints over the
container, so the titlebar cutout has to be applied here as well. */
-html.electron [data-collapsible="icon"][data-side="left"] [data-slot="sidebar-inner"] {
+html.electron.mac [data-collapsible="icon"][data-side="left"] [data-slot="sidebar-inner"] {
background: linear-gradient(
to bottom,
transparent 0,
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index ff81b2d2f..c373434af 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -10,6 +10,7 @@ import "@xterm/xterm/css/xterm.css";
import "./index.css";
import { isElectron } from "./env";
+import { isMacPlatform } from "./lib/utils";
import { getRouter } from "./router";
import { APP_DISPLAY_NAME } from "./branding";
import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
@@ -21,6 +22,11 @@ const router = getRouter(history);
if (isElectron) {
document.documentElement.classList.add("electron");
+ // macOS draws its window controls over the app's top-left corner; Windows
+ // and Linux put them on the right, where they never meet the sidebar.
+ if (isMacPlatform(navigator.platform)) {
+ document.documentElement.classList.add("mac");
+ }
syncDocumentWindowControlsOverlayClass();
}
From a165d162f049fed2cde7d404d3118835cd696c31 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 03:25:20 -0400
Subject: [PATCH 17/82] Describe projects on hover and finish the rail's top
edge
A project row shows a name and a count; the rail shows a favicon and
nothing else. Neither says where the project lives, how much of it is
moving, or when it last did anything -- which is most of what you want
before opening one. Hovering a project now answers all three, in both
densities.
The card hangs off the row's sticky wrapper rather than the row button.
The button is where dnd-kit puts its activator ref, and Base UI merges
event handlers but explicitly does not merge refs, so triggering from
the button would have quietly broken project drag-reorder. The wrapper
carries no drag props and covers the same area.
The rail's top edge now lands on the chat header's rule rather than the
titlebar height, so the divider reads as one line across the window
instead of stopping where the rail begins. That needed the header's
measured position, so --chat-header-bottom now defaults to the titlebar
height instead of zero: the rail stays correctly inset before any header
mounts and on routes that have none, which zero would not have survived.
The summary builder moved to Sidebar.logic where the pure sidebar logic
lives and where it could be tested -- its counting, its notion of "live",
and its empty-project case were all untested while it sat in the card.
---
.../src/components/ChatsDestinationView.tsx | 7 +-
apps/web/src/components/Sidebar.logic.test.ts | 65 +++++
apps/web/src/components/Sidebar.logic.ts | 55 ++++
apps/web/src/components/Sidebar.tsx | 275 +++++++++---------
.../components/sidebar/DeckRail.browser.tsx | 20 +-
apps/web/src/components/sidebar/DeckRail.tsx | 66 ++---
.../src/components/sidebar/OnDeckSection.tsx | 7 +-
.../components/sidebar/ProjectHoverCard.tsx | 91 ++++++
.../components/sidebar/ThreadHoverCard.tsx | 73 ++---
apps/web/src/components/sidebar/hoverCard.tsx | 81 ++++++
apps/web/src/index.css | 19 +-
11 files changed, 521 insertions(+), 238 deletions(-)
create mode 100644 apps/web/src/components/sidebar/ProjectHoverCard.tsx
create mode 100644 apps/web/src/components/sidebar/hoverCard.tsx
diff --git a/apps/web/src/components/ChatsDestinationView.tsx b/apps/web/src/components/ChatsDestinationView.tsx
index d5d508c19..b32e9a37f 100644
--- a/apps/web/src/components/ChatsDestinationView.tsx
+++ b/apps/web/src/components/ChatsDestinationView.tsx
@@ -19,7 +19,8 @@ import { buildThreadRouteParams } from "../threadRoutes";
import { formatRelativeTimeLabel } from "../timestampFormat";
import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators";
import { resolveThreadStatusPill } from "./Sidebar.logic";
-import { ThreadHoverCard, ThreadHoverCardGroup } from "./sidebar/ThreadHoverCard";
+import { ThreadHoverCard } from "./sidebar/ThreadHoverCard";
+import { SidebarHoverCardGroup } from "./sidebar/hoverCard";
/**
* The Chats destination: general chats are threads with no project, so they get
@@ -92,7 +93,7 @@ export function ChatsDestinationView() {
No chats yet.
) : (
-
+
{/* The rows repeat the page title's shape closely enough that a rule
alone read as more list. A label gives the list a head of its own. */}
@@ -132,7 +133,7 @@ export function ChatsDestinationView() {
))}
+
);
}
-
-/**
- * Groups thread hover cards so a list behaves like one surface.
- *
- * The delay is there to stop a card firing while the pointer is only crossing
- * the list on its way elsewhere. Once one card is open that intent is no longer
- * in doubt, so neighbouring rows swap instantly, and `timeout` keeps the group
- * warm briefly after the last one closes.
- */
-export function ThreadHoverCardGroup({ children }: { children: ReactNode }) {
- return (
-
- {children}
-
- );
-}
diff --git a/apps/web/src/components/sidebar/hoverCard.tsx b/apps/web/src/components/sidebar/hoverCard.tsx
new file mode 100644
index 000000000..1d89e6a20
--- /dev/null
+++ b/apps/web/src/components/sidebar/hoverCard.tsx
@@ -0,0 +1,81 @@
+import type { ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+import type { ThreadStatusPill } from "../Sidebar.logic";
+import { ThreadStatusDot } from "../ThreadStatusIndicators";
+import { TooltipProvider } from "../ui/tooltip";
+
+/**
+ * Shared shell for the sidebar's hover cards, so a thread card and a project
+ * card read as the same object with different contents.
+ */
+export const HOVER_CARD_POPUP_CLASS_NAME =
+ "w-64 rounded-lg p-3 text-left text-popover-foreground text-sm shadow-none elevate-popover";
+
+export function HoverCardTitle({ children }: { children: ReactNode }) {
+ return (
+
{children}
+ );
+}
+
+/** Status on the left, when it last moved on the right, ruled off from the details. */
+export function HoverCardStatusLine({
+ status,
+ idleLabel = "Idle",
+ timestamp,
+}: {
+ status: ThreadStatusPill | null;
+ idleLabel?: string;
+ timestamp: string | null;
+}) {
+ return (
+
+ );
+}
+
+export function HoverCardDetails({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
+
+/**
+ * Groups hover cards so a list behaves like one surface.
+ *
+ * The delay is there to stop a card firing while the pointer is only crossing
+ * the list on its way elsewhere. Once one card is open that intent is no longer
+ * in doubt, so neighbouring rows swap instantly, and `timeout` keeps the group
+ * warm briefly after the last one closes.
+ */
+export function SidebarHoverCardGroup({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index dac5bc484..99b9395bd 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -7,8 +7,10 @@
:root {
--workspace-topbar-height: 52px;
/* Measured bottom edge of the chat header (see useChatHeaderBottomVarRef in
- rightPanelLayout.ts); portal overlays use it to start below the header. */
- --chat-header-bottom: 0px;
+ rightPanelLayout.ts); portal overlays use it to start below the header, and
+ the collapsed rail aligns its top edge to it. Defaults to the titlebar
+ height so the rail is still inset before any header has mounted. */
+ --chat-header-bottom: var(--workspace-topbar-height);
--workspace-controls-top: 0px;
--workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem);
--workspace-controls-right: calc(env(safe-area-inset-right) + 0.75rem);
@@ -297,8 +299,8 @@ html.electron.mac [data-collapsible="icon"][data-side="left"] [data-slot="sideba
background: linear-gradient(
to bottom,
transparent 0,
- transparent var(--workspace-topbar-height),
- var(--border) var(--workspace-topbar-height)
+ transparent var(--chat-header-bottom),
+ var(--border) var(--chat-header-bottom)
)
right / 1px 100% no-repeat;
}
@@ -306,11 +308,16 @@ html.electron.mac [data-collapsible="icon"][data-side="left"] [data-slot="sideba
/* The rail surface itself lives on the inner element, which paints over the
container, so the titlebar cutout has to be applied here as well. */
html.electron.mac [data-collapsible="icon"][data-side="left"] [data-slot="sidebar-inner"] {
+ /* The last pixel before the rail begins is the same rule the chat header
+ draws, so the divider reads as one line across the whole window rather
+ than stopping at the rail. */
background: linear-gradient(
to bottom,
transparent 0,
- transparent var(--workspace-topbar-height),
- var(--rail) var(--workspace-topbar-height)
+ transparent calc(var(--chat-header-bottom) - 1px),
+ var(--border) calc(var(--chat-header-bottom) - 1px),
+ var(--border) var(--chat-header-bottom),
+ var(--rail) var(--chat-header-bottom)
);
}
From 1aa200566ca4bf8b3f178e15ef1c3a73c5dfed4e Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 03:32:12 -0400
Subject: [PATCH 18/82] Close the divider's corner and warm the whole sidebar's
hover group
The divider left an unpainted pixel exactly where it met the chat
header's rule. The horizontal line was painted by the rail's inner
element, which spans the content box, while the vertical edge was
painted by the container starting below the divider -- so the one pixel
belonging to both was claimed by neither. The divider is drawn on the
container now, where it reaches across the edge strip.
Hover cards were also grouped per list, so the delay reset whenever you
crossed from one list to another: deck row to project row, or project to
project, each waited again. There is now one group per sidebar surface,
which is the honest scope -- the delay exists to survive a pointer
crossing the sidebar, and the sidebar is one thing to cross. Measured:
359ms for the first card, 41ms to a project card, 36ms back to a thread.
---
apps/web/src/components/Sidebar.tsx | 75 +++++-----
apps/web/src/components/sidebar/DeckRail.tsx | 138 +++++++++---------
.../src/components/sidebar/OnDeckSection.tsx | 37 +++--
apps/web/src/index.css | 32 ++--
4 files changed, 146 insertions(+), 136 deletions(-)
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index add655bae..ba8b3c6fe 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -178,6 +178,7 @@ import {
import { OnDeckSection, type OnDeckEntry } from "./sidebar/OnDeckSection";
import { DeckRail, type DeckRailProject } from "./sidebar/DeckRail";
import { ProjectHoverCard } from "./sidebar/ProjectHoverCard";
+import { SidebarHoverCardGroup } from "./sidebar/hoverCard";
import {
DESTINATION_ICONS,
DestinationBand,
@@ -3851,42 +3852,44 @@ export default function Sidebar() {
) : (
<>
-
+
+
+
diff --git a/apps/web/src/components/sidebar/DeckRail.tsx b/apps/web/src/components/sidebar/DeckRail.tsx
index 4c3d7a381..bc8cb85eb 100644
--- a/apps/web/src/components/sidebar/DeckRail.tsx
+++ b/apps/web/src/components/sidebar/DeckRail.tsx
@@ -104,55 +104,55 @@ export const DeckRail = memo(function DeckRail(props: DeckRailProps) {
const needsUserCount = countThreadsNeedingUser(entries.map((entry) => entry.status));
return (
-
+
);
});
diff --git a/apps/web/src/components/sidebar/OnDeckSection.tsx b/apps/web/src/components/sidebar/OnDeckSection.tsx
index a8920892f..086eca4ec 100644
--- a/apps/web/src/components/sidebar/OnDeckSection.tsx
+++ b/apps/web/src/components/sidebar/OnDeckSection.tsx
@@ -9,7 +9,6 @@ import { ThreadStatusLabel } from "../ThreadStatusIndicators";
import { SectionLabel } from "../ui/threadline";
import { ProjectFavicon } from "../ProjectFavicon";
import { ThreadHoverCard } from "./ThreadHoverCard";
-import { SidebarHoverCardGroup } from "./hoverCard";
import { SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
@@ -179,25 +178,23 @@ export const OnDeckSection = memo(function OnDeckSection(props: OnDeckSectionPro
On deck
-
-
- {entries.map((entry) => {
- const threadKey = scopedThreadKey(
- scopeThreadRef(entry.thread.environmentId, entry.thread.id),
- );
- return (
-
- );
- })}
-
-
+
+ {entries.map((entry) => {
+ const threadKey = scopedThreadKey(
+ scopeThreadRef(entry.thread.environmentId, entry.thread.id),
+ );
+ return (
+
+ );
+ })}
+
);
});
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 99b9395bd..20b9db01c 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -296,27 +296,37 @@ html.electron.mac [data-collapsible="icon"][data-side="left"] [data-slot="sideba
can start below the titlebar too; a real border spans the full height and
would strike the window controls. */
border-right-color: transparent;
- background: linear-gradient(
+ background:
+ /* the vertical edge, starting where the rail surface does */
+ linear-gradient(
+ to bottom,
+ transparent 0,
+ transparent var(--chat-header-bottom),
+ var(--border) var(--chat-header-bottom)
+ )
+ right / 1px 100% no-repeat,
+ /* the divider, drawn here rather than on the inner element so it reaches
+ across the edge strip too: painted on the inner it stopped one pixel
+ short and left a hole exactly where it met the chat header's rule */
+ linear-gradient(
to bottom,
transparent 0,
- transparent var(--chat-header-bottom),
- var(--border) var(--chat-header-bottom)
- )
- right / 1px 100% no-repeat;
+ transparent calc(var(--chat-header-bottom) - 1px),
+ var(--border) calc(var(--chat-header-bottom) - 1px),
+ var(--border) var(--chat-header-bottom),
+ transparent var(--chat-header-bottom)
+ );
}
/* The rail surface itself lives on the inner element, which paints over the
container, so the titlebar cutout has to be applied here as well. */
html.electron.mac [data-collapsible="icon"][data-side="left"] [data-slot="sidebar-inner"] {
- /* The last pixel before the rail begins is the same rule the chat header
- draws, so the divider reads as one line across the whole window rather
- than stopping at the rail. */
+ /* Surface only; the divider above it belongs to the container so it can span
+ the full width. */
background: linear-gradient(
to bottom,
transparent 0,
- transparent calc(var(--chat-header-bottom) - 1px),
- var(--border) calc(var(--chat-header-bottom) - 1px),
- var(--border) var(--chat-header-bottom),
+ transparent var(--chat-header-bottom),
var(--rail) var(--chat-header-bottom)
);
}
From 78c71a0fb1d185dc9f1014982c7a44c53ca94a89 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 12:48:27 -0400
Subject: [PATCH 19/82] Add browser panel state, keyed per thread
Groundwork for the center split. The panel's open state, address and
viewport preset are per-thread because what you are looking at belongs
to the work you are doing: opening a second thread should not inherit
the first one's page.
The split ratio is deliberately not per-thread. It is a window
preference, and columns that jump width when you switch threads would
be worse than one ratio you set once. It clamps to a third either way,
since a browser squeezed to a sliver is worse than a closed one.
---
apps/web/src/browserPanelStore.ts | 125 ++++++++++++++++++++++++++++++
1 file changed, 125 insertions(+)
create mode 100644 apps/web/src/browserPanelStore.ts
diff --git a/apps/web/src/browserPanelStore.ts b/apps/web/src/browserPanelStore.ts
new file mode 100644
index 000000000..39dd2f8e5
--- /dev/null
+++ b/apps/web/src/browserPanelStore.ts
@@ -0,0 +1,125 @@
+/**
+ * Zustand store for the in-app browser panel, keyed by scoped thread identity.
+ *
+ * The panel is per-thread because what you are looking at belongs to the work
+ * you are doing: opening a second thread should not inherit the first one's
+ * page. The split ratio is deliberately *not* per-thread — it is a window
+ * preference, and having the columns jump width when you switch threads would
+ * be worse than having one ratio you set once.
+ */
+
+import { scopedThreadKey } from "@threadlines/client-runtime";
+import { type ScopedThreadRef } from "@threadlines/contracts";
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
+
+import { resolveStorage } from "./lib/storage";
+
+/** Named viewports, so "does this work on a phone" is one click rather than arithmetic. */
+export const BROWSER_VIEWPORT_PRESETS = [
+ { id: "fill", label: "Fill", width: null, height: null },
+ { id: "iphone-15", label: "iPhone 15", width: 393, height: 852 },
+ { id: "ipad", label: "iPad", width: 834, height: 1112 },
+ { id: "laptop", label: "Laptop", width: 1280, height: 800 },
+] as const;
+
+export type BrowserViewportPresetId = (typeof BROWSER_VIEWPORT_PRESETS)[number]["id"];
+
+export interface ThreadBrowserState {
+ open: boolean;
+ /** The address currently loaded, or null before the panel has been pointed anywhere. */
+ url: string | null;
+ viewportPresetId: BrowserViewportPresetId;
+}
+
+const BROWSER_PANEL_STORAGE_KEY = "threadlines:browser-panel:v1";
+
+/**
+ * Fraction of the split occupied by the chat column. Clamped rather than free:
+ * either pane below roughly a third stops being usable, and a browser squeezed
+ * to a sliver is worse than a closed one.
+ */
+export const BROWSER_SPLIT_MIN_CHAT_FRACTION = 0.3;
+export const BROWSER_SPLIT_MAX_CHAT_FRACTION = 0.7;
+export const DEFAULT_BROWSER_SPLIT_CHAT_FRACTION = 0.5;
+
+const DEFAULT_THREAD_BROWSER_STATE: ThreadBrowserState = {
+ open: false,
+ url: null,
+ viewportPresetId: "fill",
+};
+
+export function clampBrowserSplitFraction(fraction: number): number {
+ if (!Number.isFinite(fraction)) {
+ return DEFAULT_BROWSER_SPLIT_CHAT_FRACTION;
+ }
+ return Math.min(
+ BROWSER_SPLIT_MAX_CHAT_FRACTION,
+ Math.max(BROWSER_SPLIT_MIN_CHAT_FRACTION, fraction),
+ );
+}
+
+interface BrowserPanelStoreState {
+ browserStateByThreadKey: Record;
+ splitChatFraction: number;
+ setBrowserOpen: (threadRef: ScopedThreadRef, open: boolean) => void;
+ toggleBrowserOpen: (threadRef: ScopedThreadRef) => void;
+ setBrowserUrl: (threadRef: ScopedThreadRef, url: string) => void;
+ setViewportPreset: (threadRef: ScopedThreadRef, presetId: BrowserViewportPresetId) => void;
+ setSplitChatFraction: (fraction: number) => void;
+}
+
+function updateThread(
+ state: BrowserPanelStoreState,
+ threadRef: ScopedThreadRef,
+ patch: Partial,
+): Pick {
+ const key = scopedThreadKey(threadRef);
+ const current = state.browserStateByThreadKey[key] ?? DEFAULT_THREAD_BROWSER_STATE;
+ return {
+ browserStateByThreadKey: {
+ ...state.browserStateByThreadKey,
+ [key]: { ...current, ...patch },
+ },
+ };
+}
+
+export const useBrowserPanelStore = create()(
+ persist(
+ (set) => ({
+ browserStateByThreadKey: {},
+ splitChatFraction: DEFAULT_BROWSER_SPLIT_CHAT_FRACTION,
+ setBrowserOpen: (threadRef, open) => set((state) => updateThread(state, threadRef, { open })),
+ toggleBrowserOpen: (threadRef) =>
+ set((state) => {
+ const current = selectThreadBrowserState(state.browserStateByThreadKey, threadRef);
+ return updateThread(state, threadRef, { open: !current.open });
+ }),
+ setBrowserUrl: (threadRef, url) => set((state) => updateThread(state, threadRef, { url })),
+ setViewportPreset: (threadRef, viewportPresetId) =>
+ set((state) => updateThread(state, threadRef, { viewportPresetId })),
+ setSplitChatFraction: (fraction) =>
+ set(() => ({ splitChatFraction: clampBrowserSplitFraction(fraction) })),
+ }),
+ {
+ name: BROWSER_PANEL_STORAGE_KEY,
+ storage: createJSONStorage(() =>
+ resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
+ ),
+ partialize: (state) => ({
+ browserStateByThreadKey: state.browserStateByThreadKey,
+ splitChatFraction: state.splitChatFraction,
+ }),
+ },
+ ),
+);
+
+export function selectThreadBrowserState(
+ browserStateByThreadKey: Record,
+ threadRef: ScopedThreadRef | null,
+): ThreadBrowserState {
+ if (threadRef === null) {
+ return DEFAULT_THREAD_BROWSER_STATE;
+ }
+ return browserStateByThreadKey[scopedThreadKey(threadRef)] ?? DEFAULT_THREAD_BROWSER_STATE;
+}
From fe6f11ae1a08d14cfb2258fbf0d756bb5461e39a Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 13:08:23 -0400
Subject: [PATCH 20/82] Split the centre for an in-app browser
The browser sits beside the chat rather than in the right rail: it is
the other half of the work surface, not an annotation of it, and the
rail belongs to source control. Both panes honour one ratio, stored as
a fraction so the split survives a window resize, and clamped so a hard
drag cannot collapse either side to a sliver.
The preview is an Electron rather than an iframe. An iframe
cannot be navigated, inspected or driven cross-origin, which is the
entire point of the panel; a is a real Chromium tab that CDP
can attach to later. Because it lives in the renderer rather than as a
native view over the window, it stays inside normal CSS layout and
dialogs still stack above it.
It runs in one persistent partition, so signing in to the app you are
building is something you do once rather than once per thread -- and so
the agent will later drive a tab that is already signed in. That session
is kept apart from the app's own, and preview content gets no Node, no
preload and a short permission allowlist.
Manual only for now: no agent control yet.
---
apps/desktop/src/preview/PreviewSession.ts | 104 ++++++++
apps/desktop/src/window/DesktopWindow.ts | 17 ++
apps/web/src/components/ChatView.tsx | 41 ++-
.../src/components/browser/BrowserPanel.tsx | 243 ++++++++++++++++++
.../components/browser/BrowserSplitHandle.tsx | 83 ++++++
.../src/components/browser/previewUrl.test.ts | 22 ++
apps/web/src/components/browser/previewUrl.ts | 33 +++
.../chat/ChatHeader.render.test.tsx | 3 +
apps/web/src/components/chat/ChatHeader.tsx | 34 ++-
9 files changed, 578 insertions(+), 2 deletions(-)
create mode 100644 apps/desktop/src/preview/PreviewSession.ts
create mode 100644 apps/web/src/components/browser/BrowserPanel.tsx
create mode 100644 apps/web/src/components/browser/BrowserSplitHandle.tsx
create mode 100644 apps/web/src/components/browser/previewUrl.test.ts
create mode 100644 apps/web/src/components/browser/previewUrl.ts
diff --git a/apps/desktop/src/preview/PreviewSession.ts b/apps/desktop/src/preview/PreviewSession.ts
new file mode 100644
index 000000000..e27e748b9
--- /dev/null
+++ b/apps/desktop/src/preview/PreviewSession.ts
@@ -0,0 +1,104 @@
+/**
+ * The browser session behind the in-app preview.
+ *
+ * One persistent partition, shared by every preview tab, so signing in to the
+ * app you are building is something you do once rather than once per thread —
+ * and so the agent driving a tab operates inside that same signed-in session
+ * rather than a blank one it cannot authenticate.
+ *
+ * Kept separate from the app's own session: preview content is untrusted, and
+ * it has no business reading Threadlines' cookies.
+ */
+
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import * as Context from "effect/Context";
+import { session, type Session } from "electron";
+
+export const PREVIEW_PARTITION = "persist:threadlines-preview";
+
+/**
+ * Permissions preview content may use. Deliberately short: a page under
+ * development has no reason to reach the microphone or camera, and the
+ * default-deny keeps a mistyped URL from prompting for hardware.
+ *
+ * `clipboard-sanitized-write` (not `clipboard-write`, which Electron does not
+ * recognise) is what `navigator.clipboard.writeText()` checks, so framework
+ * error overlays with a "copy" button keep working.
+ */
+const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([
+ "clipboard-read",
+ "clipboard-sanitized-write",
+ "notifications",
+]);
+
+export class PreviewSessionCreationError extends Schema.TaggedErrorClass()(
+ "PreviewSessionCreationError",
+ { partition: Schema.String, cause: Schema.Defect() },
+) {
+ override get message(): string {
+ return `Failed to create the preview browser session (partition ${this.partition}).`;
+ }
+}
+
+export class PreviewSession extends Context.Service<
+ PreviewSession,
+ {
+ readonly partition: string;
+ readonly getSession: () => Effect.Effect;
+ readonly clearBrowsingData: () => Effect.Effect;
+ }
+>()("@threadlines/desktop/preview/PreviewSession") {}
+
+export const make = Effect.gen(function* PreviewSessionMake() {
+ let cached: Session | null = null;
+
+ const getSession = Effect.fn("PreviewSession.getSession")(function* () {
+ if (cached !== null) {
+ return cached;
+ }
+ return yield* Effect.try({
+ try: () => {
+ const previewSession = session.fromPartition(PREVIEW_PARTITION);
+ // Present as an ordinary Chrome build. Sites that sniff for Electron
+ // serve degraded or blocked experiences, which would make the preview
+ // disagree with the browser the user checks against.
+ previewSession.setUserAgent(
+ previewSession
+ .getUserAgent()
+ .replace(/Electron\/[\d.]+ /, "")
+ .replace(/\s*Threadlines\/[\d.]+/, ""),
+ );
+ previewSession.setPermissionRequestHandler((_contents, permission, callback) => {
+ callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission));
+ });
+ // Async clipboard writes consult the *check* handler rather than the
+ // request handler, so both have to agree or copy buttons fail.
+ previewSession.setPermissionCheckHandler((_contents, permission) =>
+ ALLOWED_PREVIEW_PERMISSIONS.has(permission),
+ );
+ cached = previewSession;
+ return previewSession;
+ },
+ catch: (cause) => new PreviewSessionCreationError({ partition: PREVIEW_PARTITION, cause }),
+ });
+ });
+
+ return PreviewSession.of({
+ partition: PREVIEW_PARTITION,
+ getSession,
+ clearBrowsingData: Effect.fn("PreviewSession.clearBrowsingData")(function* () {
+ const previewSession = yield* getSession();
+ yield* Effect.tryPromise({
+ try: () =>
+ previewSession.clearStorageData({
+ storages: ["cookies", "localstorage", "indexdb", "serviceworkers"],
+ }),
+ catch: (cause) => new PreviewSessionCreationError({ partition: PREVIEW_PARTITION, cause }),
+ });
+ }),
+ });
+}).pipe(Effect.withSpan("PreviewSession.make"));
+
+export const layer = Layer.effect(PreviewSession, make);
diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts
index 118126b63..b4db0fdb4 100644
--- a/apps/desktop/src/window/DesktopWindow.ts
+++ b/apps/desktop/src/window/DesktopWindow.ts
@@ -1,4 +1,5 @@
import type { DesktopMenuActionPayload } from "@threadlines/contracts";
+import { PREVIEW_PARTITION } from "../preview/PreviewSession.ts";
import { fromJsonStringPretty } from "@threadlines/shared/schemaJson";
import * as Context from "effect/Context";
import * as Data from "effect/Data";
@@ -323,9 +324,25 @@ const make = Effect.gen(function* () {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
+ // The in-app browser preview is a in the renderer, which
+ // keeps it inside normal CSS layout instead of a native view floating
+ // over the window. `will-attach-webview` below is what makes enabling
+ // this safe.
+ webviewTag: true,
},
});
+ // Preview content is untrusted. Whatever the renderer asks for, a preview
+ // webview gets no Node, no custom preload, and the preview partition --
+ // never the app's own session.
+ window.webContents.on("will-attach-webview", (_event, webPreferences, params) => {
+ delete webPreferences.preload;
+ webPreferences.nodeIntegration = false;
+ webPreferences.contextIsolation = true;
+ webPreferences.sandbox = true;
+ params.partition = PREVIEW_PARTITION;
+ });
+
if (Option.isSome(persistedWindowState) && persistedWindowState.value.isMaximized) {
window.maximize();
}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index b4bcec26a..6c27871d9 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -248,6 +248,9 @@ import {
type RevertConfirmView,
} from "./ChatView.logic";
import { useLocalStorage } from "~/hooks/useLocalStorage";
+import { selectThreadBrowserState, useBrowserPanelStore } from "../browserPanelStore";
+import { BrowserPanel } from "./browser/BrowserPanel";
+import { BrowserSplitHandle } from "./browser/BrowserSplitHandle";
import { useComposerHandleContext } from "../composerHandleContext";
import {
useServerAvailableEditors,
@@ -2504,6 +2507,26 @@ export default function ChatView(props: ChatViewProps) {
: (storeServerTerminalLaunchContext ?? null);
// Default true while loading to avoid toolbar flicker.
const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
+ const browserPanelState = useBrowserPanelStore((store) =>
+ selectThreadBrowserState(store.browserStateByThreadKey, routeThreadRef),
+ );
+ const toggleBrowserOpen = useBrowserPanelStore((store) => store.toggleBrowserOpen);
+ const setBrowserOpen = useBrowserPanelStore((store) => store.setBrowserOpen);
+ const splitChatFraction = useBrowserPanelStore((store) => store.splitChatFraction);
+ const setSplitChatFraction = useBrowserPanelStore((store) => store.setSplitChatFraction);
+ // General chats have no project and therefore no dev server to look at.
+ const browserAvailable = !isGeneralChatThread;
+ const browserOpen = browserAvailable && browserPanelState.open;
+ const handleToggleBrowser = useCallback(() => {
+ if (routeThreadRef !== null) {
+ toggleBrowserOpen(routeThreadRef);
+ }
+ }, [routeThreadRef, toggleBrowserOpen]);
+ const handleCloseBrowser = useCallback(() => {
+ if (routeThreadRef !== null) {
+ setBrowserOpen(routeThreadRef, false);
+ }
+ }, [routeThreadRef, setBrowserOpen]);
const workingTreeDiffStat = useMemo(
() => resolveWorkingTreeDiffStat(gitStatusQuery.data ?? null),
[gitStatusQuery.data],
@@ -6012,6 +6035,9 @@ export default function ChatView(props: ChatViewProps) {
sourceControlToggleShortcutLabel={sourceControlPanelShortcutLabel}
sourceControlOpen={rightPanelEngaged && !isGeneralChatThread}
sourceControlAvailable={activeProject !== undefined && !isGeneralChatThread}
+ browserAvailable={browserAvailable}
+ browserOpen={browserOpen}
+ onToggleBrowser={handleToggleBrowser}
workingTreeDiffStat={workingTreeDiffStat}
fileBrowserAvailable={!isGeneralChatThread}
taskProgress={taskProgress}
@@ -6099,7 +6125,10 @@ export default function ChatView(props: ChatViewProps) {
{/* Main content area */}
);
}
+
+/**
+ * What is listening right now, offered as destinations.
+ *
+ * A blank address bar is a worse starting point than it looks: the port a dev
+ * server picked is exactly the thing nobody remembers. Rows rather than tiles,
+ * separated by rules, so it reads as a list of facts.
+ */
+function LocalServerPicker({ onSelect }: { onSelect: (port: number) => void }) {
+ const [servers, setServers] = useState>([]);
+ const [scanned, setScanned] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ const scan = () => {
+ void window.desktopBridge?.previewLocalServers?.().then((found) => {
+ if (!cancelled) {
+ setServers(found);
+ setScanned(true);
+ }
+ });
+ };
+ scan();
+ // Servers start and stop while the panel is open; a stale list would send
+ // the user to a port that has since died.
+ const interval = window.setInterval(scan, 4000);
+ return () => {
+ cancelled = true;
+ window.clearInterval(interval);
+ };
+ }, []);
+
+ return (
+
+
+
+ Local servers
+
+ {servers.length === 0 ? (
+
+ {scanned ? "Nothing is listening right now." : "Looking for local servers…"}
+
+ );
+}
+
function NavButton({
label,
disabled,
@@ -288,24 +458,6 @@ function NavButton({
);
}
-/**
- * The preview is a Chromium , which only exists in the desktop app.
- * Rather than degrade to an iframe -- which cannot be driven, inspected, or
- * navigated cross-origin -- the web build says so plainly.
- */
-function BrowserUnavailableNotice() {
- return (
-
-
-
The browser preview needs the desktop app.
-
- It runs a real Chromium tab so pages can be inspected and driven, which a browser tab cannot
- host.
-
-
- );
-}
-
/**
* What is listening right now, offered as destinations.
*
@@ -376,3 +528,21 @@ function LocalServerPicker({ onSelect }: { onSelect: (port: number) => void }) {
);
}
+
+/**
+ * The preview is a Chromium , which only exists in the desktop app.
+ * Rather than degrade to an iframe -- which cannot be driven, inspected, or
+ * navigated cross-origin -- the web build says so plainly.
+ */
+function BrowserUnavailableNotice() {
+ return (
+
+
+
The browser preview needs the desktop app.
+
+ It runs a real Chromium tab so pages can be inspected and driven, which a browser tab cannot
+ host.
+
+
+ );
+}
From 672d2428c1d4e41375ea6e49987a4417c6a55912 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 14:25:35 -0400
Subject: [PATCH 28/82] Let the browser change where it sits, and add its
overflow menu
Docking to the bottom, expanding over the chat, and a menu for the
things that belong to the whole tab rather than the page: open in the
default browser, copy the address, developer tools, and clearing the
preview's cookies.
The layout controls sit with the tabs and the page controls stay in the
toolbar, because they act on different things -- one moves the panel,
the other moves the page.
Dock side is a window preference like the split ratio, so it is
remembered; expansion is not. Reopening the app to a hidden chat would
look like the thread had vanished, which is a worse first impression
than re-expanding.
Clearing cookies reloads the tab afterwards: signing out is the point,
and a page left on screen still looking signed in would suggest it had
not worked. Developer tools open undocked, since the guest is a small
pane and docking them into it would leave no page to inspect.
---
apps/desktop/src/ipc/DesktopIpcHandlers.ts | 4 +
apps/desktop/src/ipc/channels.ts | 2 +
apps/desktop/src/ipc/methods/preview.ts | 21 ++++
apps/desktop/src/main.ts | 2 +
apps/desktop/src/preload.ts | 4 +
apps/desktop/src/preview/PreviewAutomation.ts | 9 ++
apps/web/src/browserPanelStore.ts | 21 ++++
apps/web/src/components/ChatView.tsx | 27 +++-
.../src/components/browser/BrowserPanel.tsx | 117 +++++++++++++++++-
.../components/browser/BrowserSplitHandle.tsx | 30 +++--
packages/contracts/src/ipc.ts | 2 +
11 files changed, 224 insertions(+), 15 deletions(-)
diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts
index 6582915ad..36d46753d 100644
--- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts
+++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts
@@ -54,6 +54,8 @@ import {
previewDetach,
previewEvaluate,
previewLocalServers,
+ previewClearBrowsingData,
+ previewOpenDevTools,
previewScreenshot,
previewSnapshot,
previewStatus,
@@ -92,6 +94,8 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {
yield* ipc.handle(previewType);
yield* ipc.handle(previewLocalServers);
yield* ipc.handle(previewScreenshot);
+ yield* ipc.handle(previewOpenDevTools);
+ yield* ipc.handle(previewClearBrowsingData);
yield* ipc.handle(getServerExposureState);
yield* ipc.handle(setServerExposureMode);
diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts
index f5a647660..7c3ddd12e 100644
--- a/apps/desktop/src/ipc/channels.ts
+++ b/apps/desktop/src/ipc/channels.ts
@@ -10,6 +10,8 @@ export const PREVIEW_CLICK_CHANNEL = "desktop:preview-click";
export const PREVIEW_TYPE_CHANNEL = "desktop:preview-type";
export const PREVIEW_LOCAL_SERVERS_CHANNEL = "desktop:preview-local-servers";
export const PREVIEW_SCREENSHOT_CHANNEL = "desktop:preview-screenshot";
+export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
+export const PREVIEW_CLEAR_BROWSING_DATA_CHANNEL = "desktop:preview-clear-browsing-data";
export const SET_THEME_CHANNEL = "desktop:set-theme";
export const SET_TASKBAR_STATUS_CHANNEL = "desktop:set-taskbar-status";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts
index 2b818735b..7052dcfc2 100644
--- a/apps/desktop/src/ipc/methods/preview.ts
+++ b/apps/desktop/src/ipc/methods/preview.ts
@@ -13,6 +13,7 @@ import * as Schema from "effect/Schema";
import * as LocalServers from "../../preview/LocalServers.ts";
import * as PreviewAutomation from "../../preview/PreviewAutomation.ts";
+import * as PreviewSession from "../../preview/PreviewSession.ts";
import * as IpcChannels from "../channels.ts";
import { makeIpcMethod } from "../DesktopIpc.ts";
@@ -105,3 +106,23 @@ export const previewScreenshot = makeIpcMethod({
return yield* automation.screenshot(input.webContentsId);
}),
});
+
+export const previewOpenDevTools = makeIpcMethod({
+ channel: IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL,
+ payload: DesktopPreviewTargetSchema,
+ result: Schema.Void,
+ handler: Effect.fn("desktop.ipc.preview.openDevTools")(function* (input) {
+ const automation = yield* PreviewAutomation.PreviewAutomation;
+ yield* automation.openDevTools(input.webContentsId);
+ }),
+});
+
+export const previewClearBrowsingData = makeIpcMethod({
+ channel: IpcChannels.PREVIEW_CLEAR_BROWSING_DATA_CHANNEL,
+ payload: Schema.Void,
+ result: Schema.Void,
+ handler: Effect.fn("desktop.ipc.preview.clearBrowsingData")(function* () {
+ const session = yield* PreviewSession.PreviewSession;
+ yield* session.clearBrowsingData();
+ }),
+});
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index 1698eadf4..e81848e16 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -45,6 +45,7 @@ import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.t
import * as DesktopScreenCapture from "./screenCapture/DesktopScreenCapture.ts";
import * as LocalServers from "./preview/LocalServers.ts";
import * as PreviewAutomation from "./preview/PreviewAutomation.ts";
+import * as PreviewSession from "./preview/PreviewSession.ts";
import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts";
import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";
import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
@@ -177,6 +178,7 @@ const desktopApplicationLayer = Layer.mergeAll(
DesktopScreenCapture.layer,
PreviewAutomation.layer,
LocalServers.layer,
+ PreviewSession.layer,
DesktopShellEnvironment.layer,
DesktopRelay.layer,
desktopSshLayer,
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
index a44a3a4fa..d240b7239 100644
--- a/apps/desktop/src/preload.ts
+++ b/apps/desktop/src/preload.ts
@@ -118,6 +118,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {
previewType: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_TYPE_CHANNEL, input),
previewLocalServers: () => ipcRenderer.invoke(IpcChannels.PREVIEW_LOCAL_SERVERS_CHANNEL),
previewScreenshot: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_SCREENSHOT_CHANNEL, input),
+ previewOpenDevTools: (input) =>
+ ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, input),
+ previewClearBrowsingData: () =>
+ ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_BROWSING_DATA_CHANNEL),
setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
setTaskbarStatus: (input) => ipcRenderer.invoke(IpcChannels.SET_TASKBAR_STATUS_CHANNEL, input),
showContextMenu: (items, position) =>
diff --git a/apps/desktop/src/preview/PreviewAutomation.ts b/apps/desktop/src/preview/PreviewAutomation.ts
index 9d0827b4f..32b7be95a 100644
--- a/apps/desktop/src/preview/PreviewAutomation.ts
+++ b/apps/desktop/src/preview/PreviewAutomation.ts
@@ -119,6 +119,7 @@ export class PreviewAutomation extends Context.Service<
readonly snapshot: (
webContentsId: number,
) => Effect.Effect;
+ readonly openDevTools: (webContentsId: number) => Effect.Effect;
readonly screenshot: (
webContentsId: number,
) => Effect.Effect;
@@ -372,6 +373,14 @@ export const make = Effect.gen(function* PreviewAutomationMake() {
}
return { ...buildStatus(webContentsId, contents), elements };
}),
+ openDevTools: Effect.fn("PreviewAutomation.openDevTools")(function* (webContentsId: number) {
+ const contents = yield* resolve(webContentsId);
+ // Undocked: the guest is a small pane inside our layout, and devtools
+ // docked into it would leave almost nothing of the page visible.
+ yield* Effect.sync(() => {
+ contents.openDevTools({ mode: "detach" });
+ });
+ }),
screenshot: Effect.fn("PreviewAutomation.screenshot")(function* (webContentsId: number) {
const contents = yield* resolve(webContentsId);
// Captured through CDP rather than capturePage so the image is the page
diff --git a/apps/web/src/browserPanelStore.ts b/apps/web/src/browserPanelStore.ts
index cc0c384e2..2de8bec81 100644
--- a/apps/web/src/browserPanelStore.ts
+++ b/apps/web/src/browserPanelStore.ts
@@ -52,6 +52,15 @@ export const BROWSER_SPLIT_MIN_CHAT_FRACTION = 0.3;
export const BROWSER_SPLIT_MAX_CHAT_FRACTION = 0.7;
export const DEFAULT_BROWSER_SPLIT_CHAT_FRACTION = 0.5;
+/**
+ * Where the browser sits relative to the chat.
+ *
+ * A window preference rather than a per-thread one, like the split ratio: the
+ * panel jumping from one edge to another when you switch threads would be
+ * disorienting in a way that a remembered position is not.
+ */
+export type BrowserDockSide = "right" | "bottom";
+
let tabSequence = 0;
function nextTabId(): string {
tabSequence += 1;
@@ -125,6 +134,9 @@ export function nextActiveTabId(
interface BrowserPanelStoreState {
browserStateByThreadKey: Record;
splitChatFraction: number;
+ dockSide: BrowserDockSide;
+ /** Hides the chat so the page gets the whole centre; the split is remembered. */
+ expanded: boolean;
setBrowserOpen: (threadRef: ScopedThreadRef, open: boolean) => void;
toggleBrowserOpen: (threadRef: ScopedThreadRef) => void;
openTab: (threadRef: ScopedThreadRef) => void;
@@ -138,6 +150,8 @@ interface BrowserPanelStoreState {
presetId: BrowserViewportPresetId,
) => void;
setSplitChatFraction: (fraction: number) => void;
+ setDockSide: (side: BrowserDockSide) => void;
+ toggleExpanded: () => void;
}
function updateThread(
@@ -168,6 +182,8 @@ export const useBrowserPanelStore = create()(
(set) => ({
browserStateByThreadKey: {},
splitChatFraction: DEFAULT_BROWSER_SPLIT_CHAT_FRACTION,
+ dockSide: "right",
+ expanded: false,
setBrowserOpen: (threadRef, open) =>
set((state) => updateThread(state, threadRef, (current) => ({ ...current, open }))),
toggleBrowserOpen: (threadRef) =>
@@ -218,6 +234,8 @@ export const useBrowserPanelStore = create()(
),
setSplitChatFraction: (fraction) =>
set(() => ({ splitChatFraction: clampBrowserSplitFraction(fraction) })),
+ setDockSide: (side) => set(() => ({ dockSide: side })),
+ toggleExpanded: () => set((state) => ({ expanded: !state.expanded })),
}),
{
name: BROWSER_PANEL_STORAGE_KEY,
@@ -227,6 +245,9 @@ export const useBrowserPanelStore = create()(
partialize: (state) => ({
browserStateByThreadKey: state.browserStateByThreadKey,
splitChatFraction: state.splitChatFraction,
+ dockSide: state.dockSide,
+ // Expansion is deliberately not persisted: reopening the app to a
+ // hidden chat would look like the thread had vanished.
}),
},
),
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 6c27871d9..e72178374 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -2514,6 +2514,8 @@ export default function ChatView(props: ChatViewProps) {
const setBrowserOpen = useBrowserPanelStore((store) => store.setBrowserOpen);
const splitChatFraction = useBrowserPanelStore((store) => store.splitChatFraction);
const setSplitChatFraction = useBrowserPanelStore((store) => store.setSplitChatFraction);
+ const browserDockSide = useBrowserPanelStore((store) => store.dockSide);
+ const browserExpanded = useBrowserPanelStore((store) => store.expanded);
// General chats have no project and therefore no dev server to look at.
const browserAvailable = !isGeneralChatThread;
const browserOpen = browserAvailable && browserPanelState.open;
@@ -6122,11 +6124,20 @@ export default function ChatView(props: ChatViewProps) {
void confirmRevertThread();
}}
/>
- {/* Main content area */}
-
+ {/* Main content area. The browser docks beside or below the chat; when
+ expanded it takes the whole area and the chat is not rendered. */}
+
{
const url = `http://localhost:${port}`;
@@ -365,6 +345,7 @@ export function BrowserPanel({
threadRef={threadRef}
isActive={tab.id === activeTabId}
preset={preset}
+ colorScheme={resolvedTheme}
onNavState={setNavState}
register={(element) => {
if (element === null) {
@@ -441,6 +422,7 @@ function PreviewTabFrame({
threadRef,
isActive,
preset,
+ colorScheme,
onNavState,
register,
}: {
@@ -448,6 +430,7 @@ function PreviewTabFrame({
threadRef: ScopedThreadRef;
isActive: boolean;
preset: (typeof BROWSER_VIEWPORT_PRESETS)[number];
+ colorScheme: "light" | "dark";
onNavState: (state: NavState) => void;
register: (element: PreviewWebview | null) => void;
}) {
@@ -456,6 +439,19 @@ function PreviewTabFrame({
const setTabTitle = useBrowserPanelStore((store) => store.setTabTitle);
const isActiveRef = useRef(isActive);
isActiveRef.current = isActive;
+ const colorSchemeRef = useRef(colorScheme);
+ colorSchemeRef.current = colorScheme;
+
+ useEffect(() => {
+ const webview = elementRef.current;
+ if (webview === null || !isElectron) {
+ return;
+ }
+ const id = callWhenReady(() => webview.getWebContentsId());
+ if (id !== null) {
+ void window.desktopBridge?.previewSetColorScheme?.({ webContentsId: id, colorScheme });
+ }
+ }, [colorScheme]);
useEffect(() => {
const webview = elementRef.current;
@@ -465,7 +461,15 @@ function PreviewTabFrame({
let attachedId: number | null = null;
const onAttached = () => {
attachedId = webview.getWebContentsId();
- void window.desktopBridge?.previewAttach?.({ webContentsId: attachedId });
+ void window.desktopBridge?.previewAttach?.({ webContentsId: attachedId }).then(() => {
+ // A page that respects prefers-color-scheme should follow the app
+ // rather than the OS: a dark app hosting a stubbornly light page is
+ // the jarring part, and it is the app the page is embedded in.
+ void window.desktopBridge?.previewSetColorScheme?.({
+ webContentsId: attachedId as number,
+ colorScheme: colorSchemeRef.current,
+ });
+ });
};
const publishNav = (loading: boolean) => {
// Only the visible tab drives the toolbar; a background tab finishing a
@@ -522,7 +526,7 @@ function PreviewTabFrame({
register(element as PreviewWebview | null);
}}
{...(isActive ? { "data-testid": "browser-panel-webview" } : {})}
- className="h-full w-full border border-border bg-white"
+ className="h-full w-full bg-background"
style={
preset.width === null
? undefined
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index 23ead10c1..c2c6760ff 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -433,6 +433,12 @@ export const DesktopPreviewStatusSchema = Schema.Struct({
});
export type DesktopPreviewStatus = typeof DesktopPreviewStatusSchema.Type;
+export const DesktopPreviewColorSchemeInputSchema = Schema.Struct({
+ webContentsId: Schema.Number,
+ colorScheme: Schema.Literals(["light", "dark"]),
+});
+export type DesktopPreviewColorSchemeInput = typeof DesktopPreviewColorSchemeInputSchema.Type;
+
export const DesktopPreviewScreenshotSchema = Schema.Struct({
dataUrl: Schema.String,
width: Schema.Number,
@@ -717,6 +723,7 @@ export interface DesktopBridge {
previewLocalServers?: () => Promise;
previewScreenshot?: (input: DesktopPreviewTarget) => Promise;
previewOpenDevTools?: (input: DesktopPreviewTarget) => Promise;
+ previewSetColorScheme?: (input: DesktopPreviewColorSchemeInput) => Promise;
previewClearBrowsingData?: () => Promise;
setTheme: (theme: DesktopTheme) => Promise;
showContextMenu: (
From 244b0f7a658ab9538b8aa8f8c34e037610ae27e4 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 14:46:02 -0400
Subject: [PATCH 30/82] List servers that serve pages, not every open socket
A machine has many listeners that are not web servers, and showing them
all buried the two that mattered. Measured on a real machine: a dev
server answers 200 text/html, an AirPlay receiver answers 403 with no
content type, and a Handoff daemon does not speak HTTP at all. Serving
HTML is the line that separates them, so each candidate is now asked for
a page and kept only if it returns one. Fourteen entries became five.
That also fixes the labels. Three dev servers all called "node" are
indistinguishable; their titles are exactly what you were looking for,
so the page's title leads and the process name is the fallback.
Probes are cached per port and pid, so a stable server is not re-fetched
on every poll while a restart on the same port still updates within a
few seconds. Redirects are followed, since a dev server pointing at its
app shell is still a web server.
The trade-off: a server that answers with JSON rather than HTML no
longer appears. This panel opens pages, so that seems the right side to
err on, but it is a real exclusion rather than an oversight.
Also gives the status dot room; it was sitting against the row's edge.
---
apps/desktop/src/preview/LocalServers.ts | 37 ++++++-
.../src/preview/probeHttpServer.test.ts | 29 +++++
apps/desktop/src/preview/probeHttpServer.ts | 100 ++++++++++++++++++
.../src/components/browser/BrowserPanel.tsx | 11 +-
packages/contracts/src/ipc.ts | 2 +
5 files changed, 172 insertions(+), 7 deletions(-)
create mode 100644 apps/desktop/src/preview/probeHttpServer.test.ts
create mode 100644 apps/desktop/src/preview/probeHttpServer.ts
diff --git a/apps/desktop/src/preview/LocalServers.ts b/apps/desktop/src/preview/LocalServers.ts
index 50cd7b649..98e40d861 100644
--- a/apps/desktop/src/preview/LocalServers.ts
+++ b/apps/desktop/src/preview/LocalServers.ts
@@ -13,9 +13,16 @@ import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
-import { parseListeningPorts } from "./parseListeningPorts.ts";
+import { parseListeningPorts, type ListeningPort } from "./parseListeningPorts.ts";
+import { probeHttpServer } from "./probeHttpServer.ts";
const LSOF_TIMEOUT_MS = 5_000;
+/**
+ * How long a probe result stands. Long enough that a stable server is not
+ * re-fetched on every poll, short enough that restarting one on the same port
+ * updates its title within a few seconds.
+ */
+const PROBE_CACHE_MS = 30_000;
export class LocalServers extends Context.Service<
LocalServers,
@@ -23,6 +30,32 @@ export class LocalServers extends Context.Service<
>()("@threadlines/desktop/preview/LocalServers") {}
export const make = Effect.gen(function* LocalServersMake() {
+ // Keyed by port and pid together: a different process on the same port is a
+ // different server, and should not inherit the previous one's answer.
+ const probeCache = new Map();
+
+ const probeAll = async (ports: ReadonlyArray) => {
+ const now = Date.now();
+ const results = await Promise.all(
+ ports.map(async (entry) => {
+ const key = `${entry.port}:${entry.pid}`;
+ const cached = probeCache.get(key);
+ const probe =
+ cached !== undefined && now - cached.at < PROBE_CACHE_MS
+ ? cached.result
+ : await probeHttpServer(entry.port);
+ probeCache.set(key, { at: now, result: probe });
+ return probe === null ? null : { ...entry, title: probe.title };
+ }),
+ );
+ for (const [key, value] of probeCache) {
+ if (now - value.at >= PROBE_CACHE_MS) {
+ probeCache.delete(key);
+ }
+ }
+ return results.filter((entry) => entry !== null);
+ };
+
return LocalServers.of({
scan: Effect.fn("LocalServers.scan")(function* () {
// Never fails the caller: an empty list renders as "nothing running",
@@ -42,7 +75,7 @@ export const make = Effect.gen(function* LocalServersMake() {
// lsof exits non-zero when some descriptors are unreadable
// while still printing the ones it could read, so stdout is
// used even on error rather than discarding a good result.
- resolve(parseListeningPorts(stdout ?? ""));
+ void probeAll(parseListeningPorts(stdout ?? "")).then(resolve);
},
);
}),
diff --git a/apps/desktop/src/preview/probeHttpServer.test.ts b/apps/desktop/src/preview/probeHttpServer.test.ts
new file mode 100644
index 000000000..1b6cad256
--- /dev/null
+++ b/apps/desktop/src/preview/probeHttpServer.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { extractHtmlTitle, isHtmlContentType } from "./probeHttpServer.ts";
+
+describe("extractHtmlTitle", () => {
+ it("reads a title across attributes and newlines", () => {
+ expect(extractHtmlTitle('My\n Dev App')).toBe(
+ "My Dev App",
+ );
+ });
+
+ it("returns null when there is no usable title", () => {
+ expect(extractHtmlTitle("")).toBeNull();
+ expect(extractHtmlTitle("")).toBeNull();
+ });
+});
+
+describe("isHtmlContentType", () => {
+ it("accepts html with a charset", () => {
+ expect(isHtmlContentType("text/html; charset=utf-8")).toBe(true);
+ });
+
+ it("rejects everything else, including a missing header", () => {
+ // AirPlay answers without a content type; an API serves JSON. Neither is a
+ // page you would open in the preview.
+ expect(isHtmlContentType(undefined)).toBe(false);
+ expect(isHtmlContentType("application/json")).toBe(false);
+ });
+});
diff --git a/apps/desktop/src/preview/probeHttpServer.ts b/apps/desktop/src/preview/probeHttpServer.ts
new file mode 100644
index 000000000..591c58725
--- /dev/null
+++ b/apps/desktop/src/preview/probeHttpServer.ts
@@ -0,0 +1,100 @@
+/**
+ * Decides whether a listening port is something you could actually open.
+ *
+ * A machine has many listeners that are not web servers: Handoff daemons,
+ * AirPlay receivers, database sockets, an app's own internal ports. Listing
+ * them all makes the useful entries hard to find, so each candidate is asked
+ * for a page and kept only if it serves one.
+ *
+ * Measured against a real machine: a dev server answers 200 text/html, AirPlay
+ * answers 403 with no content type, and a Handoff daemon does not speak HTTP at
+ * all. Serving HTML is the line that separates them.
+ */
+
+import { get, type IncomingMessage } from "node:http";
+
+const PROBE_TIMEOUT_MS = 800;
+/** Enough for ; a title after this is not worth holding a socket open for. */
+const MAX_TITLE_BYTES = 16_000;
+const MAX_REDIRECTS = 2;
+
+/**
+ * A page's title, for labelling. Titles are far more useful than process names:
+ * three dev servers all called "node" are indistinguishable, while their titles
+ * are exactly what the user is looking for.
+ */
+export function extractHtmlTitle(html: string): string | null {
+ const match = /]*>([\s\S]{0,200}?)<\/title>/i.exec(html);
+ if (match?.[1] === undefined) {
+ return null;
+ }
+ const title = match[1].replace(/\s+/g, " ").trim();
+ return title === "" ? null : title;
+}
+
+export function isHtmlContentType(contentType: string | undefined): boolean {
+ return contentType !== undefined && contentType.toLowerCase().includes("text/html");
+}
+
+export interface HttpProbeResult {
+ title: string | null;
+}
+
+/** Resolves null for anything that is not an HTML-serving HTTP endpoint. */
+export function probeHttpServer(
+ port: number,
+ redirectsLeft = MAX_REDIRECTS,
+): Promise {
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (result: HttpProbeResult | null) => {
+ if (!settled) {
+ settled = true;
+ resolve(result);
+ }
+ };
+
+ const request = get(
+ { host: "127.0.0.1", port, path: "/", timeout: PROBE_TIMEOUT_MS },
+ (response: IncomingMessage) => {
+ const status = response.statusCode ?? 0;
+ const location = response.headers.location;
+ if (status >= 300 && status < 400 && location !== undefined && redirectsLeft > 0) {
+ // A dev server redirecting to its app shell is still a web server;
+ // the page that matters is the one it points at.
+ response.resume();
+ const target = /^https?:\/\//i.test(location) ? null : location;
+ if (target === null) {
+ finish(null);
+ return;
+ }
+ void probeHttpServer(port, redirectsLeft - 1).then(finish);
+ return;
+ }
+ if (status >= 400 || !isHtmlContentType(response.headers["content-type"])) {
+ response.resume();
+ finish(null);
+ return;
+ }
+
+ let body = "";
+ response.setEncoding("utf8");
+ response.on("data", (chunk: string) => {
+ body += chunk;
+ if (body.length >= MAX_TITLE_BYTES) {
+ response.destroy();
+ finish({ title: extractHtmlTitle(body) });
+ }
+ });
+ response.on("end", () => finish({ title: extractHtmlTitle(body) }));
+ response.on("error", () => finish(null));
+ },
+ );
+
+ request.on("timeout", () => {
+ request.destroy();
+ finish(null);
+ });
+ request.on("error", () => finish(null));
+ });
+}
diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx
index 415772fbe..29f3bb099 100644
--- a/apps/web/src/components/browser/BrowserPanel.tsx
+++ b/apps/web/src/components/browser/BrowserPanel.tsx
@@ -608,12 +608,12 @@ function LocalServerPicker({ onSelect }: { onSelect: (port: number) => void }) {
return (
-
+
Local servers
{servers.length === 0 ? (
-
+
{scanned ? "Nothing is listening right now." : "Looking for local servers…"}
);
}
+
+/**
+ * Size and zoom for the page under test.
+ *
+ * Hidden until asked for, because sizing is an occasional task and a permanent
+ * control costs toolbar width on every page you ever look at. Width and height
+ * are editable rather than a fixed menu of devices: the presets fill them in,
+ * and any other size is reachable by typing it.
+ */
+function DeviceToolbar({
+ viewport,
+ zoomFactor,
+ onViewportChange,
+ onZoomChange,
+ onClose,
+}: {
+ viewport: BrowserViewport;
+ zoomFactor: number;
+ onViewportChange: (viewport: BrowserViewport) => void;
+ onZoomChange: (factor: number) => void;
+ onClose: () => void;
+}) {
+ const matchingPreset = BROWSER_VIEWPORT_PRESETS.find(
+ (entry) => entry.width === viewport.width && entry.height === viewport.height,
+ );
+
+ return (
+
);
}
+/**
+ * Drag handles on the page's own edges.
+ *
+ * Resizing the device by dragging is the natural gesture, and it means finding
+ * a breakpoint does not require resizing the whole app around it. A shield is
+ * raised while dragging because the pointer crosses the guest, which is a
+ * separate process and would otherwise swallow the events.
+ */
+function ViewportResizeHandle({
+ edge,
+ viewport,
+ onResize,
+}: {
+ edge: "right" | "bottom" | "corner";
+ viewport: BrowserViewport;
+ onResize: (viewport: BrowserViewport) => void;
+}) {
+ const [dragging, setDragging] = useState(false);
+ const originRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
+
+ const onPointerDown = (event: React.PointerEvent) => {
+ event.preventDefault();
+ originRef.current = {
+ x: event.clientX,
+ y: event.clientY,
+ width: viewport.width ?? 0,
+ height: viewport.height ?? 0,
+ };
+ setDragging(true);
+ event.currentTarget.setPointerCapture(event.pointerId);
+ };
+
+ const onPointerMove = (event: React.PointerEvent) => {
+ if (!dragging) {
+ return;
+ }
+ const origin = originRef.current;
+ const width =
+ edge === "bottom" ? origin.width : Math.max(160, origin.width + (event.clientX - origin.x));
+ const height =
+ edge === "right" ? origin.height : Math.max(160, origin.height + (event.clientY - origin.y));
+ onResize({ width: Math.round(width), height: Math.round(height) });
+ };
+
+ const endDrag = (event: React.PointerEvent) => {
+ setDragging(false);
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+ };
+
+ const position =
+ edge === "right"
+ ? "-right-1.5 inset-y-0 w-3 cursor-col-resize"
+ : edge === "bottom"
+ ? "-bottom-1.5 inset-x-0 h-3 cursor-row-resize"
+ : "-bottom-1.5 -right-1.5 size-3 cursor-nwse-resize";
+
+ return (
+ <>
+ {dragging ? : null}
+
+
+
+ >
+ );
+}
+
function NavButton({
label,
disabled,
@@ -733,29 +898,34 @@ function DeviceToolbar({
viewport,
zoomFactor,
onViewportChange,
+ onFitToPanel,
onZoomChange,
onClose,
}: {
viewport: BrowserViewport;
zoomFactor: number;
onViewportChange: (viewport: BrowserViewport) => void;
+ onFitToPanel: () => void;
onZoomChange: (factor: number) => void;
onClose: () => void;
}) {
+ // "Responsive" is the label for a size nobody named, which is what dragging
+ // produces; a matching preset takes precedence over it.
const matchingPreset = BROWSER_VIEWPORT_PRESETS.find(
- (entry) => entry.width === viewport.width && entry.height === viewport.height,
+ (entry) =>
+ entry.width !== null && entry.width === viewport.width && entry.height === viewport.height,
);
return (
-
+
onZoomChange(1)}
>
{Math.round(zoomFactor * 100)}%
@@ -871,7 +1040,7 @@ function DimensionInput({
aria-label={label}
data-testid={`browser-device-${label.toLowerCase()}`}
inputMode="numeric"
- className="w-14 rounded-md border border-border bg-background px-1.5 py-1 text-center font-mono text-[11px] text-muted-foreground outline-none focus:border-ring focus:text-foreground"
+ className="w-12 shrink-0 rounded-md border border-border bg-background px-1 py-1 text-center font-mono text-[11px] text-muted-foreground outline-none focus:border-ring focus:text-foreground"
placeholder="auto"
value={draft}
onChange={(event) => setDraft(event.target.value)}
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index d7b4d0a85..9eca77897 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -433,6 +433,14 @@ export const DesktopPreviewStatusSchema = Schema.Struct({
});
export type DesktopPreviewStatus = typeof DesktopPreviewStatusSchema.Type;
+export const DesktopPreviewViewportInputSchema = Schema.Struct({
+ webContentsId: Schema.Number,
+ /** null clears the override and lets the page size itself to the element. */
+ width: Schema.NullOr(Schema.Number),
+ height: Schema.NullOr(Schema.Number),
+});
+export type DesktopPreviewViewportInput = typeof DesktopPreviewViewportInputSchema.Type;
+
export const DesktopPreviewColorSchemeInputSchema = Schema.Struct({
webContentsId: Schema.Number,
colorScheme: Schema.Literals(["light", "dark"]),
@@ -726,6 +734,7 @@ export interface DesktopBridge {
previewScreenshot?: (input: DesktopPreviewTarget) => Promise;
previewOpenDevTools?: (input: DesktopPreviewTarget) => Promise;
previewSetColorScheme?: (input: DesktopPreviewColorSchemeInput) => Promise;
+ previewSetViewport?: (input: DesktopPreviewViewportInput) => Promise;
previewClearBrowsingData?: () => Promise;
previewClearCache?: () => Promise;
setTheme: (theme: DesktopTheme) => Promise;
From 3cbc92e65454f0f358f0b9fcb3d230094f9105ce Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 15:31:02 -0400
Subject: [PATCH 33/82] Let the device row shed controls before it overflows
Measured against a shrinking window: the row was fine down to a 392px
panel and ran 27px past its edge at 342px. Rather than shave another few
pixels and wait for the next width to break it, the panel is now a query
container and the row drops controls as it narrows -- zoom first, since
it is the widest and least used, then rotate. The size fields and the
close button, which are the reason the row exists, stay at every width.
Sizing against the panel rather than the window matters because dragging
the split is what actually squeezes these rows; the window can be wide
while the panel is not.
Also replaces the rotate icon. The icon set offers a square with a
refresh arrow, which reads as "reload", and a pair of devices, which
reads as "responsive"; neither says "turn this on its side". Drawn
instead as a screen caught on its corner mid-turn. An arc and arrowhead
were tried alongside it and only muddied the glyph at 14px.
---
apps/web/src/components/Icons.tsx | 28 ++++++++++++++++++
.../src/components/browser/BrowserPanel.tsx | 29 ++++++++++---------
2 files changed, 43 insertions(+), 14 deletions(-)
diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx
index 7d7400cc8..4877490d0 100644
--- a/apps/web/src/components/Icons.tsx
+++ b/apps/web/src/components/Icons.tsx
@@ -60,6 +60,34 @@ export const ThreadlinesGlyph: Icon = (props) => {
);
};
+/**
+ * Device rotation: a screen turned on its corner, mid-swap between portrait and
+ * landscape, with the arc it travels through.
+ *
+ * Drawn rather than taken from the icon set, which offers a square with a
+ * refresh arrow (reads as "reload") and a pair of devices (reads as
+ * "responsive"). Neither says "turn this on its side", which is the one thing
+ * this control does.
+ */
+export const RotateDeviceIcon: Icon = ({ className, ...props }) => (
+
+);
+
export const SourceControlIcon: Icon = ({ className, ...props }) => (
-
+
+
-
-
-
+
+
+
+
);
}
From 83d1f3f3f078a55ca4a4975359fb7d22fc20fee2 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 15:54:36 -0400
Subject: [PATCH 34/82] Draw the rotate control as a device with arrows around
it
The previous glyph was the screen itself tilted on its corner, which
reads as a shape rather than an action. An upright device with arrows
turning around it says what the control does.
---
apps/web/src/components/Icons.tsx | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx
index 4877490d0..5c500a741 100644
--- a/apps/web/src/components/Icons.tsx
+++ b/apps/web/src/components/Icons.tsx
@@ -61,13 +61,11 @@ export const ThreadlinesGlyph: Icon = (props) => {
};
/**
- * Device rotation: a screen turned on its corner, mid-swap between portrait and
- * landscape, with the arc it travels through.
+ * Device rotation: an upright screen with arrows turning around it.
*
* Drawn rather than taken from the icon set, which offers a square with a
* refresh arrow (reads as "reload") and a pair of devices (reads as
- * "responsive"). Neither says "turn this on its side", which is the one thing
- * this control does.
+ * "responsive"). Neither says "turn this on its side".
*/
export const RotateDeviceIcon: Icon = ({ className, ...props }) => (
);
From 90d4d03924cc444d1e25c690407237d99fa98521 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 16:01:40 -0400
Subject: [PATCH 35/82] Space the diffstat toggle, and open the address
elsewhere
The counts turned an icon button into a small label, and it kept the
spacing of an icon button. The gap between icon and counts came from the
control's own gap, which is sized to separate icons -- eight pixels
between a mark and the number it belongs to, while the icon itself sat
against the hover fill and the last digit against the far edge. With the
counts present it now takes room on both sides and less between them:
the gap goes from twelve pixels to two.
Adds a control at the end of the address field for opening the current
page in the default browser. It is the same action the overflow menu
offers, but inside the field it reads as "this address, elsewhere"
rather than as another thing acting on the page, and it is where a
browser puts it.
---
.../src/components/browser/BrowserPanel.tsx | 27 +++++++++++++++++--
apps/web/src/components/chat/ChatHeader.tsx | 11 ++++++--
2 files changed, 34 insertions(+), 4 deletions(-)
diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx
index e7efd0960..c955960ef 100644
--- a/apps/web/src/components/browser/BrowserPanel.tsx
+++ b/apps/web/src/components/browser/BrowserPanel.tsx
@@ -369,15 +369,38 @@ export function BrowserPanel({
-
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx
index 39ce19b0a..c88371c22 100644
--- a/apps/web/src/components/chat/ChatHeader.tsx
+++ b/apps/web/src/components/chat/ChatHeader.tsx
@@ -344,7 +344,14 @@ export const ChatHeader = memo(function ChatHeader({
+ +{workingTreeDiffStat.insertions}
−{workingTreeDiffStat.deletions}
From df3300b7ebdf805f6bfb37367cc9f3a390470a00 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 16:42:45 -0400
Subject: [PATCH 36/82] Place the device frame at computed coordinates and fit
it to the panel
The frame is positioned by arithmetic rather than centred by flex, and
scaled by a transform on the webview itself. Both matter: Electron
positions the guest's surface from the element's own box, so a transform
on an ancestor moves the element without moving what the guest paints,
and a flex-centred element sits where its unscaled size says it should.
The geometry lives in its own module so the placement can be checked
without a browser.
Fitting is what the original complaint was about: an 852-tall phone in a
600-tall panel used to hang off the bottom, and a page that centres its
content vertically then appeared to start halfway down -- which reads as
the page having shifted rather than the frame being too big for the
space. It is scaled down to fit instead, and only ever down; blowing a
phone up to fill a wide panel would misrepresent it.
The guest keeps the CSS viewport it was asked for, so a scaled phone
still reports a phone's width. Verified by capturing the guest itself:
at iPhone 15 it renders 393x852 with all four borders and its own
reported size drawn in the middle.
---
.../src/components/browser/BrowserPanel.tsx | 109 +++++++++++++-----
.../browser/browserViewportLayout.test.ts | 67 +++++++++++
.../browser/browserViewportLayout.ts | 84 ++++++++++++++
3 files changed, 231 insertions(+), 29 deletions(-)
create mode 100644 apps/web/src/components/browser/browserViewportLayout.test.ts
create mode 100644 apps/web/src/components/browser/browserViewportLayout.ts
diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx
index c955960ef..be8c52563 100644
--- a/apps/web/src/components/browser/BrowserPanel.tsx
+++ b/apps/web/src/components/browser/BrowserPanel.tsx
@@ -42,6 +42,7 @@ import {
} from "../ui/menu";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { RotateDeviceIcon } from "../Icons";
+import { resolveBrowserViewportLayout } from "./browserViewportLayout";
import { normalizePreviewUrl } from "./previewUrl";
/**
@@ -651,23 +652,38 @@ function PreviewTabFrame({
};
}, [onNavState, setTabTitle, setTabUrl, tab.id, threadRef]);
- const framed = viewport.width !== null;
+ // The container is measured so the frame can be placed at computed
+ // coordinates: Electron positions the guest's surface from the element's own
+ // box, and a flex-centred element sits where its unscaled size says while
+ // painting somewhere else.
+ const canvasRef = useRef(null);
+ const [container, setContainer] = useState({ width: 0, height: 0 });
+ useEffect(() => {
+ const element = canvasRef.current;
+ if (element === null) {
+ return;
+ }
+ const observer = new ResizeObserver(([entry]) => {
+ if (entry !== undefined) {
+ setContainer({ width: entry.contentRect.width, height: entry.contentRect.height });
+ }
+ });
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, []);
+
+ const layout = resolveBrowserViewportLayout({ container, viewport, zoomFactor });
return (
{
@@ -675,25 +691,52 @@ function PreviewTabFrame({
register(element as PreviewWebview | null);
}}
{...(isActive ? { "data-testid": "browser-panel-webview" } : {})}
- className="h-full w-full bg-background"
- style={
- viewport.width === null
- ? undefined
- : {
- width: `${viewport.width}px`,
- height: `${viewport.height ?? 800}px`,
- flex: "none",
- }
- }
+ // `flex` is deliberate: Electron's webview uses its own display value
+ // to size the guest, and replacing it breaks painting.
+ className={cn("absolute flex bg-background", !layout.fills && "ring-1 ring-border")}
+ style={{
+ left: `${layout.x}px`,
+ top: `${layout.y}px`,
+ // Laid out unscaled and shrunk by a transform on the element, so
+ // the guest keeps the CSS viewport it was asked for.
+ width: `${layout.width / layout.scale}px`,
+ height: `${layout.height / layout.scale}px`,
+ ...(layout.scale < 1
+ ? { transform: `scale(${layout.scale})`, transformOrigin: "top left" }
+ : {}),
+ }}
partition={PREVIEW_PARTITION}
{...(tab.url ? { src: tab.url } : {})}
/>
- {framed && isActive && onResize !== undefined ? (
- <>
-
-
-
- >
+ {!layout.fills && isActive && onResize !== undefined ? (
+
+
+
+
+
) : null}
@@ -711,10 +754,13 @@ function PreviewTabFrame({
function ViewportResizeHandle({
edge,
viewport,
+ scale,
onResize,
}: {
edge: "right" | "bottom" | "corner";
viewport: BrowserViewport;
+ /** Pointer travel is in screen pixels; the device is measured in its own. */
+ scale: number;
onResize: (viewport: BrowserViewport) => void;
}) {
const [dragging, setDragging] = useState(false);
@@ -737,10 +783,15 @@ function ViewportResizeHandle({
return;
}
const origin = originRef.current;
+ const ratio = scale > 0 ? scale : 1;
const width =
- edge === "bottom" ? origin.width : Math.max(160, origin.width + (event.clientX - origin.x));
+ edge === "bottom"
+ ? origin.width
+ : Math.max(160, origin.width + (event.clientX - origin.x) / ratio);
const height =
- edge === "right" ? origin.height : Math.max(160, origin.height + (event.clientY - origin.y));
+ edge === "right"
+ ? origin.height
+ : Math.max(160, origin.height + (event.clientY - origin.y) / ratio);
onResize({ width: Math.round(width), height: Math.round(height) });
};
diff --git a/apps/web/src/components/browser/browserViewportLayout.test.ts b/apps/web/src/components/browser/browserViewportLayout.test.ts
new file mode 100644
index 000000000..d728d571f
--- /dev/null
+++ b/apps/web/src/components/browser/browserViewportLayout.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveBrowserViewportLayout } from "./browserViewportLayout";
+
+const container = { width: 400, height: 600 };
+
+describe("resolveBrowserViewportLayout", () => {
+ it("fills the panel when no size is set", () => {
+ const layout = resolveBrowserViewportLayout({
+ container,
+ viewport: { width: null, height: null },
+ });
+
+ expect(layout).toMatchObject({ x: 0, y: 0, width: 400, height: 600, scale: 1, fills: true });
+ });
+
+ it("centres a device that already fits, at its own size", () => {
+ const layout = resolveBrowserViewportLayout({
+ container,
+ viewport: { width: 200, height: 400 },
+ });
+
+ // Never scaled up: a phone blown up to fill a wide panel misrepresents it.
+ expect(layout.scale).toBe(1);
+ expect(layout).toMatchObject({ width: 200, height: 400, x: 100, y: 100 });
+ });
+
+ it("scales a device down to fit, keeping its proportions", () => {
+ const layout = resolveBrowserViewportLayout({
+ container,
+ viewport: { width: 400, height: 1200 },
+ });
+
+ expect(layout.scale).toBeCloseTo(0.5, 5);
+ expect(layout.width).toBeCloseTo(200, 5);
+ expect(layout.height).toBeCloseTo(600, 5);
+ expect(layout.y).toBe(0);
+ });
+
+ it("fits against the zoomed size, since zoom is what is drawn", () => {
+ const unzoomed = resolveBrowserViewportLayout({
+ container,
+ viewport: { width: 400, height: 600 },
+ });
+ const zoomed = resolveBrowserViewportLayout({
+ container,
+ viewport: { width: 400, height: 600 },
+ zoomFactor: 2,
+ });
+
+ expect(unzoomed.scale).toBe(1);
+ expect(zoomed.scale).toBeCloseTo(0.5, 5);
+ // Same footprint on screen: zoom doubled it, fitting halved it back.
+ expect(zoomed.width).toBeCloseTo(unzoomed.width, 5);
+ });
+
+ it("treats a nonsense zoom as no zoom rather than collapsing the frame", () => {
+ const layout = resolveBrowserViewportLayout({
+ container,
+ viewport: { width: 200, height: 300 },
+ zoomFactor: 0,
+ });
+
+ expect(layout.scale).toBe(1);
+ expect(layout.width).toBe(200);
+ });
+});
diff --git a/apps/web/src/components/browser/browserViewportLayout.ts b/apps/web/src/components/browser/browserViewportLayout.ts
new file mode 100644
index 000000000..26f739210
--- /dev/null
+++ b/apps/web/src/components/browser/browserViewportLayout.ts
@@ -0,0 +1,84 @@
+/**
+ * Geometry for the preview surface.
+ *
+ * The webview is placed at computed coordinates rather than centred by flex,
+ * and scaled by a transform on the element itself. Both matter: Electron
+ * positions the guest's surface from the element's own box, so a transform on
+ * an ancestor moves the element without moving what the guest paints, and a
+ * flex-centred element sits where its *unscaled* size says it should while
+ * drawing somewhere else. Either one leaves the page visibly offset inside its
+ * own frame.
+ *
+ * Keeping the arithmetic here rather than in the component means the placement
+ * can be checked without a browser.
+ */
+
+export interface BrowserViewport {
+ /** null means the page fills the panel and reflows with it. */
+ width: number | null;
+ height: number | null;
+}
+
+export interface BrowserViewportLayout {
+ /** The surface the frame is placed within; always the full container. */
+ canvasWidth: number;
+ canvasHeight: number;
+ /** Top-left of the frame's visible footprint, within the canvas. */
+ x: number;
+ y: number;
+ /** What the frame occupies on screen, after fitting. */
+ width: number;
+ height: number;
+ /**
+ * Presentation only: the guest keeps the CSS viewport it was asked for, so a
+ * scaled-down phone still reports a phone's width to the page.
+ */
+ scale: number;
+ fills: boolean;
+}
+
+function positiveOrOne(value: number): number {
+ return Number.isFinite(value) && value > 0 ? value : 1;
+}
+
+export function resolveBrowserViewportLayout(input: {
+ container: { width: number; height: number };
+ viewport: BrowserViewport;
+ zoomFactor?: number;
+}): BrowserViewportLayout {
+ const canvasWidth = Math.max(1, Math.round(input.container.width));
+ const canvasHeight = Math.max(1, Math.round(input.container.height));
+
+ if (input.viewport.width === null || input.viewport.height === null) {
+ return {
+ canvasWidth,
+ canvasHeight,
+ x: 0,
+ y: 0,
+ width: canvasWidth,
+ height: canvasHeight,
+ scale: 1,
+ fills: true,
+ };
+ }
+
+ const zoomFactor = positiveOrOne(input.zoomFactor ?? 1);
+ const renderedWidth = input.viewport.width * zoomFactor;
+ const renderedHeight = input.viewport.height * zoomFactor;
+ // Only ever scales down. A device smaller than the panel is shown at its own
+ // size, because blowing a phone up to fill a wide panel would misrepresent it.
+ const scale = Math.min(1, canvasWidth / renderedWidth, canvasHeight / renderedHeight);
+ const width = renderedWidth * scale;
+ const height = renderedHeight * scale;
+
+ return {
+ canvasWidth,
+ canvasHeight,
+ x: Math.max(0, Math.round((canvasWidth - width) / 2)),
+ y: Math.max(0, Math.round((canvasHeight - height) / 2)),
+ width,
+ height,
+ scale,
+ fills: false,
+ };
+}
From 04590768d19dfa1227596e1e5448503e014984e8 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 18:10:38 -0400
Subject: [PATCH 37/82] Let the user point at an element and hand it to the
agent
Element picking normally means injecting a script into the page, which
is why the reference implementation ships a 1263-line preload and turns
off context isolation to reach React's devtools hook. DevTools' own
picker is a protocol feature, so this uses that instead: Overlay's
inspect mode highlights elements as the pointer moves and reports the
node that was clicked. The guest keeps its preload stripped and its
context isolation intact, which the earlier session-isolation work
depended on.
What reaches the composer is a description, not a handle. The node id
that identified the element while picking dies with the document, and by
the time an agent acts the page may have reloaded -- but a role, an
accessible name and a selector still find it, and they are how elements
are identified everywhere else here. It appends rather than replaces, so
picking a second element mid-sentence adds to the message.
Verified in the running desktop app, driving the real toolbar button and
clicking inside the guest: the description arrives in both the draft
store and the visible editor.
Includes a fix found by reading that output rather than trusting it. An
accessibility property is { type, value } and I read it one level too
deep, so every element came back with no role or name -- reported as a
bare tag by the fallback, silently, because the lookup is best-effort.
---
apps/desktop/src/ipc/DesktopIpcHandlers.ts | 4 +
apps/desktop/src/ipc/channels.ts | 2 +
apps/desktop/src/ipc/methods/preview.ts | 21 +++
apps/desktop/src/preload.ts | 3 +
apps/desktop/src/preview/PreviewAutomation.ts | 170 ++++++++++++++++++
apps/web/src/components/ChatView.tsx | 28 +++
.../src/components/browser/BrowserPanel.tsx | 44 ++++-
apps/web/src/lib/pickedElementContext.test.ts | 46 +++++
apps/web/src/lib/pickedElementContext.ts | 40 +++++
packages/contracts/src/ipc.ts | 29 +++
10 files changed, 386 insertions(+), 1 deletion(-)
create mode 100644 apps/web/src/lib/pickedElementContext.test.ts
create mode 100644 apps/web/src/lib/pickedElementContext.ts
diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts
index d7d4484b9..591f4fab6 100644
--- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts
+++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts
@@ -58,6 +58,8 @@ import {
previewClearCache,
previewOpenDevTools,
previewScreenshot,
+ previewCancelPick,
+ previewPickElement,
previewSetColorScheme,
previewSetViewport,
previewSnapshot,
@@ -99,6 +101,8 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {
yield* ipc.handle(previewScreenshot);
yield* ipc.handle(previewOpenDevTools);
yield* ipc.handle(previewSetColorScheme);
+ yield* ipc.handle(previewPickElement);
+ yield* ipc.handle(previewCancelPick);
yield* ipc.handle(previewSetViewport);
yield* ipc.handle(previewClearBrowsingData);
yield* ipc.handle(previewClearCache);
diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts
index 5819bac50..e22dcfc2e 100644
--- a/apps/desktop/src/ipc/channels.ts
+++ b/apps/desktop/src/ipc/channels.ts
@@ -12,6 +12,8 @@ export const PREVIEW_LOCAL_SERVERS_CHANNEL = "desktop:preview-local-servers";
export const PREVIEW_SCREENSHOT_CHANNEL = "desktop:preview-screenshot";
export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme";
+export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";
+export const PREVIEW_CANCEL_PICK_CHANNEL = "desktop:preview-cancel-pick";
export const PREVIEW_SET_VIEWPORT_CHANNEL = "desktop:preview-set-viewport";
export const PREVIEW_CLEAR_BROWSING_DATA_CHANNEL = "desktop:preview-clear-browsing-data";
export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts
index 14c273a44..8a173b556 100644
--- a/apps/desktop/src/ipc/methods/preview.ts
+++ b/apps/desktop/src/ipc/methods/preview.ts
@@ -8,6 +8,7 @@ import {
DesktopPreviewSnapshotSchema,
DesktopPreviewTypeInputSchema,
DesktopPreviewStatusSchema,
+ DesktopPreviewPickedElementSchema,
DesktopPreviewTargetSchema,
} from "@threadlines/contracts";
import * as Effect from "effect/Effect";
@@ -139,6 +140,26 @@ export const previewSetColorScheme = makeIpcMethod({
}),
});
+export const previewPickElement = makeIpcMethod({
+ channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL,
+ payload: DesktopPreviewTargetSchema,
+ result: Schema.NullOr(DesktopPreviewPickedElementSchema),
+ handler: Effect.fn("desktop.ipc.preview.pickElement")(function* (input) {
+ const automation = yield* PreviewAutomation.PreviewAutomation;
+ return yield* automation.pickElement(input.webContentsId);
+ }),
+});
+
+export const previewCancelPick = makeIpcMethod({
+ channel: IpcChannels.PREVIEW_CANCEL_PICK_CHANNEL,
+ payload: DesktopPreviewTargetSchema,
+ result: Schema.Void,
+ handler: Effect.fn("desktop.ipc.preview.cancelPick")(function* (input) {
+ const automation = yield* PreviewAutomation.PreviewAutomation;
+ yield* automation.cancelPick(input.webContentsId);
+ }),
+});
+
export const previewClearCache = makeIpcMethod({
channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL,
payload: Schema.Void,
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
index e632ced0e..eb61cd455 100644
--- a/apps/desktop/src/preload.ts
+++ b/apps/desktop/src/preload.ts
@@ -124,6 +124,9 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, input),
previewSetViewport: (input) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_VIEWPORT_CHANNEL, input),
+ previewPickElement: (input) =>
+ ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, input),
+ previewCancelPick: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_CHANNEL, input),
previewClearBrowsingData: () =>
ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_BROWSING_DATA_CHANNEL),
previewClearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL),
diff --git a/apps/desktop/src/preview/PreviewAutomation.ts b/apps/desktop/src/preview/PreviewAutomation.ts
index 07bdbc74d..72d65259b 100644
--- a/apps/desktop/src/preview/PreviewAutomation.ts
+++ b/apps/desktop/src/preview/PreviewAutomation.ts
@@ -13,6 +13,7 @@
*/
import type {
+ DesktopPreviewPickedElement,
DesktopPreviewConsoleEntry,
DesktopPreviewElement,
DesktopPreviewNetworkFailure,
@@ -67,6 +68,8 @@ const MAX_SNAPSHOT_ELEMENTS = 200;
/** Enough to explain a failure without letting a chatty page grow unboundedly. */
const MAX_CONSOLE_ENTRIES = 200;
+/** Long enough to choose deliberately, short enough not to strand inspect mode. */
+const PICK_TIMEOUT_MS = 60_000;
const MAX_NETWORK_FAILURES = 100;
export class PreviewTargetMissingError extends Schema.TaggedErrorClass()(
@@ -124,6 +127,10 @@ export class PreviewAutomation extends Context.Service<
webContentsId: number,
size: { width: number | null; height: number | null },
) => Effect.Effect;
+ readonly pickElement: (
+ webContentsId: number,
+ ) => Effect.Effect;
+ readonly cancelPick: (webContentsId: number) => Effect.Effect;
readonly setColorScheme: (
webContentsId: number,
colorScheme: "light" | "dark",
@@ -161,6 +168,105 @@ export const make = Effect.gen(function* PreviewAutomationMake() {
catch: (cause) => new PreviewCommandError({ webContentsId: contents.id, method, cause }),
});
+ /**
+ * Turns a picked node into something that still means something later.
+ *
+ * Everything is read in one page-side evaluation so the description matches a
+ * single moment: reading the tag, then the text, then the box across separate
+ * round trips would let the page change underneath and produce a description
+ * that never existed.
+ */
+ const describePickedNode = (send: typeof sendCommand) =>
+ Effect.fn("PreviewAutomation.describePickedNode")(function* (
+ contents: WebContents,
+ backendNodeId: number,
+ ) {
+ const resolved = yield* send(contents, "DOM.resolveNode", { backendNodeId });
+ const objectId = (resolved as { object?: { objectId?: string } }).object?.objectId;
+ if (objectId === undefined) {
+ return null;
+ }
+
+ const description = yield* send(contents, "Runtime.callFunctionOn", {
+ objectId,
+ returnByValue: true,
+ functionDeclaration: `function () {
+ const element = this;
+ const rect = element.getBoundingClientRect();
+ // A path that a person could paste into the console: id when it is
+ // unique, otherwise tag plus nth-of-type up to a sensible depth.
+ const path = (node) => {
+ const parts = [];
+ let current = node;
+ while (current && current.nodeType === 1 && parts.length < 5) {
+ if (current.id) {
+ parts.unshift('#' + CSS.escape(current.id));
+ break;
+ }
+ const tag = current.tagName.toLowerCase();
+ const parent = current.parentElement;
+ if (!parent) {
+ parts.unshift(tag);
+ break;
+ }
+ const siblings = [...parent.children].filter((c) => c.tagName === current.tagName);
+ parts.unshift(
+ siblings.length > 1 ? tag + ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')' : tag,
+ );
+ current = parent;
+ }
+ return parts.join(' > ');
+ };
+ const text = (element.innerText || element.textContent || '').trim().replace(/\\s+/g, ' ');
+ return {
+ tagName: element.tagName.toLowerCase(),
+ selector: path(element),
+ text: text === '' ? null : text.slice(0, 200),
+ rect: {
+ x: Math.round(rect.x),
+ y: Math.round(rect.y),
+ width: Math.round(rect.width),
+ height: Math.round(rect.height),
+ },
+ url: location.href,
+ };
+ }`,
+ });
+
+ yield* send(contents, "Runtime.releaseObject", { objectId }).pipe(Effect.ignore);
+
+ const value = (description as { result?: { value?: Record } }).result?.value;
+ if (value === undefined) {
+ return null;
+ }
+
+ // Role and name come from the accessibility tree, which is how the agent
+ // identifies elements everywhere else in this feature.
+ const axNode = yield* send(contents, "Accessibility.getPartialAXTree", {
+ backendNodeId,
+ fetchRelatives: false,
+ }).pipe(Effect.orElseSucceed(() => ({}) as Record));
+ const nodes = (axNode as { nodes?: ReadonlyArray> }).nodes ?? [];
+ const first = nodes[0];
+ // An AX property is { type, value } -- the string sits one level down,
+ // not two. Reading it a level too deep yielded undefined for every
+ // element, which the fallback below then reported as "no role", silently.
+ const readValue = (field: unknown): string | null => {
+ const raw = (field as { value?: unknown } | undefined)?.value;
+ return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
+ };
+
+ return {
+ tagName: String(value.tagName ?? "element"),
+ role: first === undefined ? null : readValue(first.role),
+ name: first === undefined ? null : readValue(first.name),
+ selector: String(value.selector ?? ""),
+ text: (value.text as string | null) ?? null,
+ rect: value.rect as { x: number; y: number; width: number; height: number },
+ url: String(value.url ?? ""),
+ } satisfies DesktopPreviewPickedElement;
+ });
+
const buildStatus = (webContentsId: number, contents: WebContents): DesktopPreviewStatus => {
const tab = attached.get(webContentsId);
return {
@@ -403,6 +509,70 @@ export const make = Effect.gen(function* PreviewAutomationMake() {
mobile: false,
});
}),
+ /**
+ * Uses DevTools' own element picker rather than injecting a script into the
+ * page. The guest keeps its preload stripped and context isolation intact,
+ * and the highlight the user sees while choosing is Chromium's, so it
+ * behaves exactly as it does in DevTools.
+ */
+ pickElement: Effect.fn("PreviewAutomation.pickElement")(function* (webContentsId: number) {
+ const contents = yield* resolve(webContentsId);
+ yield* sendCommand(contents, "DOM.enable", {});
+ yield* sendCommand(contents, "Overlay.enable", {});
+ yield* sendCommand(contents, "Overlay.setInspectMode", {
+ mode: "searchForNode",
+ highlightConfig: {
+ showInfo: true,
+ contentColor: { r: 111, g: 168, b: 220, a: 0.35 },
+ borderColor: { r: 111, g: 168, b: 220, a: 0.85 },
+ },
+ });
+
+ const picked = yield* Effect.tryPromise({
+ try: () =>
+ new Promise((resolve) => {
+ let done = false;
+ const finish = (value: number | null) => {
+ if (done) return;
+ done = true;
+ contents.debugger.off("message", onMessage);
+ clearTimeout(timer);
+ resolve(value);
+ };
+ const onMessage = (
+ _event: unknown,
+ method: string,
+ params: Record,
+ ) => {
+ if (method === "Overlay.inspectNodeRequested") {
+ finish(typeof params.backendNodeId === "number" ? params.backendNodeId : null);
+ }
+ };
+ contents.debugger.on("message", onMessage);
+ // Picking is a deliberate act; if the user wanders off, stop
+ // waiting rather than leaving the page in inspect mode forever.
+ const timer = setTimeout(() => finish(null), PICK_TIMEOUT_MS);
+ }),
+ catch: (cause) => new PreviewCommandError({ webContentsId, method: "Overlay.pick", cause }),
+ });
+
+ yield* sendCommand(contents, "Overlay.setInspectMode", {
+ mode: "none",
+ highlightConfig: {},
+ }).pipe(Effect.ignore);
+
+ if (picked === null) {
+ return null;
+ }
+ return yield* describePickedNode(sendCommand)(contents, picked);
+ }),
+ cancelPick: Effect.fn("PreviewAutomation.cancelPick")(function* (webContentsId: number) {
+ const contents = yield* resolve(webContentsId);
+ yield* sendCommand(contents, "Overlay.setInspectMode", {
+ mode: "none",
+ highlightConfig: {},
+ }).pipe(Effect.ignore);
+ }),
setColorScheme: Effect.fn("PreviewAutomation.setColorScheme")(function* (
webContentsId: number,
colorScheme: "light" | "dark",
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 9b0853ba2..d8866059a 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -194,6 +194,8 @@ import { MessagesTimeline, type TimelineProposedPlanState } from "./chat/Message
import { DraftEmptyState } from "./chat/DraftEmptyState";
import { ProviderModelPicker } from "./chat/ProviderModelPicker";
import { ChatHeader, type ForkHeaderContext } from "./chat/ChatHeader";
+import type { DesktopPreviewPickedElement } from "@threadlines/contracts";
+import { describePickedElementForComposer } from "../lib/pickedElementContext";
import type { ThreadBackgroundRunItem } from "./chat/ThreadActivityPopover";
import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
import { FilePreviewDialog, type FilePreviewRequest } from "./chat/FilePreviewDialog";
@@ -1327,6 +1329,31 @@ export default function ChatView(props: ChatViewProps) {
// General Chat threads run in a hidden scratch workspace: source-control,
// scripts, and open-in affordances stay hidden even though a project exists.
const isGeneralChatThread = activeProject?.kind === "general-chat";
+ /**
+ * Describes the element rather than passing a handle to it. A backend node id
+ * dies with the document, and by the time the agent acts the page may have
+ * reloaded -- but a role, a name and a selector still find it.
+ */
+ const appendPickedElementToComposer = useCallback(
+ (element: DesktopPreviewPickedElement) => {
+ const description = describePickedElementForComposer(element);
+ // Appended rather than replacing: picking a second element while
+ // mid-sentence should add to the message, not discard it.
+ const existing = (composerRef.current?.getSendContext().prompt ?? "").trim();
+ setComposerDraftPrompt(
+ composerDraftTarget,
+ existing === "" ? description : `${existing}\n${description}`,
+ );
+ window.requestAnimationFrame(() => {
+ // The editor caches its own cursor state, so it has to be told the
+ // prompt changed underneath it or the caret lands in the old position.
+ composerRef.current?.resetCursorState({ detectTrigger: false });
+ composerRef.current?.focusAtEnd();
+ });
+ },
+ [composerDraftTarget, composerRef, setComposerDraftPrompt],
+ );
+
const insertDraftStarterPrompt = useCallback(
(text: string) => {
setComposerDraftPrompt(composerDraftTarget, text);
@@ -6416,6 +6443,7 @@ export default function ChatView(props: ChatViewProps) {
threadRef={routeThreadRef}
flexGrow={browserExpanded ? 1 : 1 - splitChatFraction}
onClose={handleCloseBrowser}
+ onPickElement={appendPickedElementToComposer}
/>
>
) : null}
diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx
index be8c52563..ca86eb04a 100644
--- a/apps/web/src/components/browser/BrowserPanel.tsx
+++ b/apps/web/src/components/browser/BrowserPanel.tsx
@@ -1,4 +1,8 @@
-import type { DesktopLocalServer, ScopedThreadRef } from "@threadlines/contracts";
+import type {
+ DesktopLocalServer,
+ DesktopPreviewPickedElement,
+ ScopedThreadRef,
+} from "@threadlines/contracts";
import { PREVIEW_PARTITION } from "@threadlines/shared/preview";
import {
ArrowLeftIcon,
@@ -8,6 +12,7 @@ import {
GlobeIcon,
MaximizeIcon,
MinimizeIcon,
+ MousePointerClickIcon,
MoreVerticalIcon,
PlusIcon,
RadioTowerIcon,
@@ -103,11 +108,14 @@ export function BrowserPanel({
threadRef,
flexGrow,
onClose,
+ onPickElement,
}: {
threadRef: ScopedThreadRef;
/** Complement of the chat column's share, so the two honour one ratio. */
flexGrow: number;
onClose: () => void;
+ /** Hands a picked element to the composer as context for the next message. */
+ onPickElement?: ((element: DesktopPreviewPickedElement) => void) | undefined;
}) {
const browserState = useBrowserPanelStore((store) =>
selectThreadBrowserState(store.browserStateByThreadKey, threadRef),
@@ -185,6 +193,29 @@ export function BrowserPanel({
[activeTabId, addressDraft, setTabUrl, threadRef],
);
+ const [picking, setPicking] = useState(false);
+ const pickElement = useCallback(async () => {
+ const webview = webviewsRef.current.get(activeTabId);
+ if (webview === null || webview === undefined || !isElectron) {
+ return;
+ }
+ const webContentsId = webview.getWebContentsId();
+ if (picking) {
+ setPicking(false);
+ await window.desktopBridge?.previewCancelPick?.({ webContentsId });
+ return;
+ }
+ setPicking(true);
+ try {
+ const element = await window.desktopBridge?.previewPickElement?.({ webContentsId });
+ if (element !== null && element !== undefined) {
+ onPickElement?.(element);
+ }
+ } finally {
+ setPicking(false);
+ }
+ }, [activeTabId, onPickElement, picking]);
+
const captureScreenshot = useCallback(() => {
const webview = webviewsRef.current.get(activeTabId);
if (webview === undefined || !isElectron) {
@@ -231,6 +262,14 @@ export function BrowserPanel({
so the address row stays free for controls that act on the page --
annotate and element-pick will want that space. */}
+ void pickElement()}
+ active={picking}
+ testId="browser-pick-element"
+ >
+
+
@@ -839,12 +878,14 @@ function NavButton({
disabled,
active,
onClick,
+ testId,
children,
}: {
label: string;
disabled?: boolean;
active?: boolean;
onClick: () => void;
+ testId?: string;
children: React.ReactNode;
}) {
return (
@@ -854,6 +895,7 @@ function NavButton({
{
+ it("leads with role and name, which is how the element is found again", () => {
+ expect(describePickedElementForComposer(base)).toBe(
+ '[selected element] button "Sign in" · selector: #cta · 220×60 at http://localhost:5173/',
+ );
+ });
+
+ it("omits text that only repeats the name", () => {
+ expect(describePickedElementForComposer(base)).not.toContain("text:");
+ });
+
+ it("includes text when it says something the name does not", () => {
+ const described = describePickedElementForComposer({
+ ...base,
+ name: "Submit",
+ text: "Submit your application",
+ });
+
+ expect(described).toContain('text: "Submit your application"');
+ });
+
+ it("falls back to the tag when the element has no accessible name", () => {
+ const described = describePickedElementForComposer({
+ ...base,
+ role: null,
+ name: null,
+ text: null,
+ });
+
+ expect(described).toContain("");
+ });
+});
diff --git a/apps/web/src/lib/pickedElementContext.ts b/apps/web/src/lib/pickedElementContext.ts
new file mode 100644
index 000000000..259012486
--- /dev/null
+++ b/apps/web/src/lib/pickedElementContext.ts
@@ -0,0 +1,40 @@
+import type { DesktopPreviewPickedElement } from "@threadlines/contracts";
+
+/**
+ * Turns a picked element into something an agent can act on later.
+ *
+ * Deliberately a description rather than a handle. The node id that identified
+ * the element while picking dies with the document, and by the time the agent
+ * reads the message the page may have reloaded -- but a role, an accessible
+ * name and a selector still find it, and they are how the agent identifies
+ * elements everywhere else in this feature.
+ *
+ * The text is meant to be read by a person too: it lands in the composer where
+ * the user can edit or delete it before sending.
+ */
+export function describePickedElementForComposer(element: DesktopPreviewPickedElement): string {
+ const parts: string[] = [];
+
+ // Role and name first: that is how you would ask someone else to find it.
+ if (element.role !== null && element.name !== null) {
+ parts.push(`${element.role} "${element.name}"`);
+ } else if (element.name !== null) {
+ parts.push(`"${element.name}"`);
+ } else {
+ parts.push(`<${element.tagName}>`);
+ }
+
+ if (element.selector !== "") {
+ parts.push(`selector: ${element.selector}`);
+ }
+
+ // Only when it says something the name did not.
+ if (element.text !== null && element.text !== element.name) {
+ const trimmed = element.text.length > 80 ? `${element.text.slice(0, 80)}…` : element.text;
+ parts.push(`text: "${trimmed}"`);
+ }
+
+ parts.push(`${element.rect.width}×${element.rect.height} at ${element.url}`);
+
+ return `[selected element] ${parts.join(" · ")}`;
+}
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index 9eca77897..f539a0646 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -464,6 +464,32 @@ export const DesktopLocalServerSchema = Schema.Struct({
});
export type DesktopLocalServer = typeof DesktopLocalServerSchema.Type;
+/**
+ * An element the user pointed at in the preview.
+ *
+ * Described rather than referenced: a backend node id is only valid for the
+ * document that produced it, so handing one to an agent that will act minutes
+ * later, possibly after a reload, would be a dangling pointer. Role and name
+ * are what the agent looks elements up by anyway.
+ */
+export const DesktopPreviewPickedElementSchema = Schema.Struct({
+ tagName: Schema.String,
+ /** Accessible role and name, matching how snapshot elements are identified. */
+ role: Schema.NullOr(Schema.String),
+ name: Schema.NullOr(Schema.String),
+ /** A CSS path good enough to find the element again by hand. */
+ selector: Schema.String,
+ text: Schema.NullOr(Schema.String),
+ rect: Schema.Struct({
+ x: Schema.Number,
+ y: Schema.Number,
+ width: Schema.Number,
+ height: Schema.Number,
+ }),
+ url: Schema.String,
+});
+export type DesktopPreviewPickedElement = typeof DesktopPreviewPickedElementSchema.Type;
+
export const DesktopPreviewSnapshotSchema = Schema.Struct({
...DesktopPreviewStatusSchema.fields,
elements: Schema.Array(DesktopPreviewElementSchema),
@@ -734,6 +760,9 @@ export interface DesktopBridge {
previewScreenshot?: (input: DesktopPreviewTarget) => Promise;
previewOpenDevTools?: (input: DesktopPreviewTarget) => Promise;
previewSetColorScheme?: (input: DesktopPreviewColorSchemeInput) => Promise;
+ /** Resolves null when picking is cancelled or times out. */
+ previewPickElement?: (input: DesktopPreviewTarget) => Promise;
+ previewCancelPick?: (input: DesktopPreviewTarget) => Promise;
previewSetViewport?: (input: DesktopPreviewViewportInput) => Promise;
previewClearBrowsingData?: () => Promise;
previewClearCache?: () => Promise;
From 854a211dd3ea72f77a3fef829ffa026be491768e Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sun, 26 Jul 2026 18:22:25 -0400
Subject: [PATCH 38/82] Carry a picked element as a composer context, not
prompt text
Pasting the description into the prompt made it something you had to
type around and delete by hand. It is the same kind of thing as a
terminal excerpt or a highlighted quote -- evidence attached to the
question rather than part of it -- so it now follows that path: a draft
context, a removable chip, and a block appended at send time next to the
others.
Picking the same element twice is one context. The second pick is
usually a re-check or a miss-click, and a duplicate chip would send the
same block to the agent twice. Identity is the selector and the page
together, so the same selector on another page stays separate.
The block still describes rather than references. The node id that
identified the element while picking dies with the document, but a role,
an accessible name and a selector survive a reload, which is how
elements are identified everywhere else in this feature.
Verified in the running desktop app: picking adds one chip labelled
button "Sign in", the prompt stays empty, picking it again leaves one
chip, and removing it leaves none.
---
apps/web/src/components/ChatView.tsx | 40 +++---
apps/web/src/components/chat/ChatComposer.tsx | 63 +++++++++
.../CompactComposerControlsMenu.browser.tsx | 1 +
.../ComposerPendingPickedElementContexts.tsx | 74 ++++++++++
apps/web/src/composerDraftStore.ts | 66 +++++++++
apps/web/src/lib/pickedElementContext.test.ts | 98 +++++++++----
apps/web/src/lib/pickedElementContext.ts | 129 ++++++++++++++----
7 files changed, 398 insertions(+), 73 deletions(-)
create mode 100644 apps/web/src/components/chat/ComposerPendingPickedElementContexts.tsx
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index d8866059a..cc65a6a06 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -195,7 +195,11 @@ import { DraftEmptyState } from "./chat/DraftEmptyState";
import { ProviderModelPicker } from "./chat/ProviderModelPicker";
import { ChatHeader, type ForkHeaderContext } from "./chat/ChatHeader";
import type { DesktopPreviewPickedElement } from "@threadlines/contracts";
-import { describePickedElementForComposer } from "../lib/pickedElementContext";
+import {
+ appendPickedElementContextsToPrompt,
+ pickedElementFromPreview,
+ type PickedElementContextDraft,
+} from "../lib/pickedElementContext";
import type { ThreadBackgroundRunItem } from "./chat/ThreadActivityPopover";
import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
import { FilePreviewDialog, type FilePreviewRequest } from "./chat/FilePreviewDialog";
@@ -1114,6 +1118,7 @@ export default function ChatView(props: ChatViewProps) {
const composerTerminalContextsRef = useRef([]);
const composerTranscriptHighlightContextsRef = useRef([]);
const composerFileSelectionContextsRef = useRef([]);
+ const composerPickedElementContextsRef = useRef([]);
const localComposerRef = useRef(null);
const composerRef = useComposerHandleContext() ?? localComposerRef;
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
@@ -1330,28 +1335,16 @@ export default function ChatView(props: ChatViewProps) {
// scripts, and open-in affordances stay hidden even though a project exists.
const isGeneralChatThread = activeProject?.kind === "general-chat";
/**
- * Describes the element rather than passing a handle to it. A backend node id
- * dies with the document, and by the time the agent acts the page may have
- * reloaded -- but a role, a name and a selector still find it.
+ * A picked element becomes a context attached to the message, not text in it.
+ * It is the same shape as a terminal excerpt or a highlighted quote: evidence
+ * carried alongside what you are asking, removable before you send, and left
+ * out of the prompt you are still writing.
*/
const appendPickedElementToComposer = useCallback(
(element: DesktopPreviewPickedElement) => {
- const description = describePickedElementForComposer(element);
- // Appended rather than replacing: picking a second element while
- // mid-sentence should add to the message, not discard it.
- const existing = (composerRef.current?.getSendContext().prompt ?? "").trim();
- setComposerDraftPrompt(
- composerDraftTarget,
- existing === "" ? description : `${existing}\n${description}`,
- );
- window.requestAnimationFrame(() => {
- // The editor caches its own cursor state, so it has to be told the
- // prompt changed underneath it or the caret lands in the old position.
- composerRef.current?.resetCursorState({ detectTrigger: false });
- composerRef.current?.focusAtEnd();
- });
+ composerRef.current?.addPickedElementContext(pickedElementFromPreview(element));
},
- [composerDraftTarget, composerRef, setComposerDraftPrompt],
+ [composerRef],
);
const insertDraftStarterPrompt = useCallback(
@@ -4279,6 +4272,7 @@ export default function ChatView(props: ChatViewProps) {
terminalContexts: composerTerminalContexts,
transcriptHighlightContexts: composerTranscriptHighlightContexts,
fileSelectionContexts: composerFileSelectionContexts,
+ pickedElementContexts: composerPickedElementContexts,
selectedModel: ctxSelectedModel,
selectedModelSelection: ctxSelectedModelSelection,
skillReferences: composerSkillReferences,
@@ -4391,6 +4385,7 @@ export default function ChatView(props: ChatViewProps) {
const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];
const composerTranscriptHighlightContextsSnapshot = [...sendableTranscriptHighlightContexts];
const composerFileSelectionContextsSnapshot = [...composerFileSelectionContexts];
+ const composerPickedElementContextsSnapshot = [...composerPickedElementContexts];
const messageTextWithTerminalContexts = appendTerminalContextsToPrompt(
promptForSend,
composerTerminalContextsSnapshot,
@@ -4399,10 +4394,14 @@ export default function ChatView(props: ChatViewProps) {
messageTextWithTerminalContexts,
composerTranscriptHighlightContextsSnapshot,
);
- const messageTextForSend = appendFileSelectionContextsToPrompt(
+ const messageTextWithFileSelections = appendFileSelectionContextsToPrompt(
messageTextWithHighlights,
composerFileSelectionContextsSnapshot,
);
+ const messageTextForSend = appendPickedElementContextsToPrompt(
+ messageTextWithFileSelections,
+ composerPickedElementContextsSnapshot,
+ );
const messageIdForSend = newMessageId();
const messageCreatedAt = new Date().toISOString();
const outgoingMessageText = formatOutgoingPrompt(
@@ -6291,6 +6290,7 @@ export default function ChatView(props: ChatViewProps) {
composerTerminalContextsRef={composerTerminalContextsRef}
composerTranscriptHighlightContextsRef={composerTranscriptHighlightContextsRef}
composerFileSelectionContextsRef={composerFileSelectionContextsRef}
+ composerPickedElementContextsRef={composerPickedElementContextsRef}
shouldAutoScrollRef={isAtEndRef}
scheduleStickToBottom={scheduleTimelineStickToBottom}
onSend={onSend}
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index d654a6ac4..21336a97f 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -36,6 +36,11 @@ import { useQuery } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer";
import { serializeComposerMentionPath } from "~/composerMentionPath";
import type { FileSelectionContextDraft } from "~/lib/fileSelectionContext";
+import {
+ pickedElementContextDedupKey,
+ type PickedElementContext,
+ type PickedElementContextDraft,
+} from "~/lib/pickedElementContext";
import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
import { providerSkillsQueryOptions } from "~/lib/providerSkillsReactQuery";
import { ComposerPendingFileSelectionContexts } from "./ComposerPendingFileSelectionContexts";
@@ -85,6 +90,7 @@ import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
import { ComposerGoalBar, type ComposerGoalSetInput } from "./ComposerGoalBar";
import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
+import { ComposerPendingPickedElementContexts } from "./ComposerPendingPickedElementContexts";
import { ComposerPendingTranscriptHighlightContexts } from "./ComposerPendingTranscriptHighlightContexts";
import { ComposerPendingTerminalContexts } from "./ComposerPendingTerminalContexts";
import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
@@ -410,6 +416,8 @@ export interface ChatComposerHandle {
addTerminalContext: (selection: TerminalContextSelection) => void;
/** Add a note attached to selected transcript text. */
addTranscriptHighlightContext: (selection: TranscriptHighlightContextSelection) => void;
+ /** Attach an element picked in the browser preview. */
+ addPickedElementContext: (context: PickedElementContext) => void;
/** Get the current prompt/effort/model state for use in send. */
getSendContext: () => {
prompt: string;
@@ -417,6 +425,7 @@ export interface ChatComposerHandle {
terminalContexts: TerminalContextDraft[];
transcriptHighlightContexts: TranscriptHighlightContextDraft[];
fileSelectionContexts: FileSelectionContextDraft[];
+ pickedElementContexts: PickedElementContextDraft[];
selectedPromptEffort: string | null;
selectedModelOptionsForDispatch: unknown;
selectedModelSelection: ModelSelection;
@@ -505,6 +514,7 @@ export interface ChatComposerProps {
composerTerminalContextsRef: React.RefObject;
composerTranscriptHighlightContextsRef: React.RefObject;
composerFileSelectionContextsRef: React.RefObject;
+ composerPickedElementContextsRef: React.RefObject;
composerRef: React.RefObject;
// Scroll
@@ -605,6 +615,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
composerTerminalContextsRef,
composerTranscriptHighlightContextsRef,
composerFileSelectionContextsRef,
+ composerPickedElementContextsRef,
shouldAutoScrollRef,
scheduleStickToBottom,
onSend,
@@ -643,6 +654,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const composerTerminalContexts = composerDraft.terminalContexts;
const composerTranscriptHighlightContexts = composerDraft.transcriptHighlightContexts;
const composerFileSelectionContexts = composerDraft.fileSelectionContexts;
+ const composerPickedElementContexts = composerDraft.pickedElementContexts;
const nonPersistedComposerImageIds = composerDraft.nonPersistedAttachmentIds;
const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
@@ -664,6 +676,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const addComposerDraftTranscriptHighlightContext = useComposerDraftStore(
(store) => store.addTranscriptHighlightContext,
);
+ const setComposerDraftPickedElementContexts = useComposerDraftStore(
+ (store) => store.setPickedElementContexts,
+ );
+ const removePickedElementContext = useCallback(
+ (contextId: string) => {
+ setComposerDraftPickedElementContexts(
+ composerDraftTarget,
+ composerPickedElementContextsRef.current.filter((context) => context.id !== contextId),
+ );
+ },
+ [composerDraftTarget, composerPickedElementContextsRef, setComposerDraftPickedElementContexts],
+ );
const removeComposerDraftTranscriptHighlightContext = useComposerDraftStore(
(store) => store.removeTranscriptHighlightContext,
);
@@ -1442,6 +1466,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
composerFileSelectionContextsRef.current = composerFileSelectionContexts;
}, [composerFileSelectionContexts, composerFileSelectionContextsRef]);
+ useEffect(() => {
+ composerPickedElementContextsRef.current = composerPickedElementContexts;
+ }, [composerPickedElementContexts, composerPickedElementContextsRef]);
+
// ------------------------------------------------------------------
// Composer menu highlight sync
// ------------------------------------------------------------------
@@ -2354,12 +2382,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
composerEditorRef.current?.focusAtEnd();
});
},
+ addPickedElementContext: (context: PickedElementContext) => {
+ if (!activeThread) return;
+ const existing = composerPickedElementContextsRef.current;
+ // Picking the same element twice is one context, not two: the second
+ // pick is usually a miss-click or a re-check, and duplicate chips would
+ // send the same block twice.
+ const key = pickedElementContextDedupKey(context);
+ if (existing.some((entry) => pickedElementContextDedupKey(entry) === key)) {
+ return;
+ }
+ setComposerDraftPickedElementContexts(composerDraftTarget, [
+ ...existing,
+ {
+ ...context,
+ id: randomUUID(),
+ threadId: activeThread.id,
+ createdAt: new Date().toISOString(),
+ },
+ ]);
+ window.requestAnimationFrame(() => {
+ composerEditorRef.current?.focusAtEnd();
+ });
+ },
getSendContext: () => ({
prompt: promptRef.current,
images: composerAttachmentsRef.current,
terminalContexts: composerTerminalContextsRef.current,
transcriptHighlightContexts: composerTranscriptHighlightContextsRef.current,
fileSelectionContexts: composerFileSelectionContextsRef.current,
+ pickedElementContexts: composerPickedElementContextsRef.current,
selectedPromptEffort,
selectedModelOptionsForDispatch,
selectedModelSelection,
@@ -2776,6 +2828,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
/>
)}
+ {!isComposerCollapsedMobile &&
+ !isComposerApprovalState &&
+ pendingUserInputs.length === 0 &&
+ composerPickedElementContexts.length > 0 && (
+
+ )}
+
{!isComposerCollapsedMobile &&
!isComposerApprovalState &&
pendingUserInputs.length === 0 &&
diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx
index eccff2ccc..53b41bad9 100644
--- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx
+++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx
@@ -76,6 +76,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
terminalContexts: [],
transcriptHighlightContexts: [],
fileSelectionContexts: [],
+ pickedElementContexts: [],
modelSelectionByProvider: {
[instanceId]: createModelSelection(instanceId, model, props?.modelSelection?.options),
},
diff --git a/apps/web/src/components/chat/ComposerPendingPickedElementContexts.tsx b/apps/web/src/components/chat/ComposerPendingPickedElementContexts.tsx
new file mode 100644
index 000000000..3ecc5a21c
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerPendingPickedElementContexts.tsx
@@ -0,0 +1,74 @@
+import { MousePointerClickIcon, XIcon } from "lucide-react";
+
+import { cn } from "~/lib/utils";
+import {
+ formatPickedElementContextLabel,
+ type PickedElementContextDraft,
+} from "~/lib/pickedElementContext";
+import { COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME } from "../composerInlineChip";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+
+interface ComposerPendingPickedElementContextsProps {
+ contexts: ReadonlyArray;
+ onRemove: (contextId: string) => void;
+ className?: string;
+}
+
+const CHIP_CONTAINER_CLASS_NAME =
+ "inline-flex max-w-56 items-center gap-0.5 rounded-md border border-border/70 bg-accent/40 py-1 pr-1 pl-2 transition-colors hover:bg-accent/60";
+
+/**
+ * Elements picked in the browser preview, shown as removable chips.
+ *
+ * A chip rather than text in the prompt: it is evidence attached to the
+ * question, so it should be dismissable without editing the sentence you are
+ * writing, and it should not be something you have to type around.
+ */
+export function ComposerPendingPickedElementContexts({
+ contexts,
+ onRemove,
+ className,
+}: ComposerPendingPickedElementContextsProps) {
+ if (contexts.length === 0) {
+ return null;
+ }
+
+ return (
+