From cb91f11dd5964a239b0a2694d687d381c3a4746a Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:18:21 -0400 Subject: [PATCH 01/82] Add On Deck sidebar mockup (working set brainstorm) --- docs/design/sidebar-working-set.html | 159 +++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/design/sidebar-working-set.html diff --git a/docs/design/sidebar-working-set.html b/docs/design/sidebar-working-set.html new file mode 100644 index 000000000..59a88e135 --- /dev/null +++ b/docs/design/sidebar-working-set.html @@ -0,0 +1,159 @@ + + + + +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.

+
+ +
1Fix reconnect race in ws.tsthreadlines
+
2Marketing footer copy passthreadlines
+
3pgvector migrationnutrilog
+
+ +
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.

+
+ +
Marketing footer copy passthreadlines
+ +
Fix reconnect race in ws.tsthreadlines
+ +
pgvector migrationnutrilog
+
Design tokens cleanupthreadlines
+
+ +
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. +

+
+ +
+ 1Fix reconnect race in ws.tsthreadlines +
+
+ 2Marketing footer copy passthreadlines +
+
+ 3pgvector migrationnutrilog +
+
+ +
+ 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.

-
- -
1Fix reconnect race in ws.tsthreadlines
-
2Marketing footer copy passthreadlines
-
3pgvector migrationnutrilog
-
- -
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. +

+
+ +
+ Marketing footer copy passthreadlines +
+ +
+ Fix reconnect race in ws.tsthreadlines +
+ +
+ pgvector migrationnutrilog +
+
+ Design tokens cleanupthreadlines +
+
+ +
+ 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.

-
- -
Marketing footer copy passthreadlines
- -
Fix reconnect race in ws.tsthreadlines
- -
pgvector migrationnutrilog
-
Design tokens cleanupthreadlines
-
- -
threadlines
-
nutrilog4
-
scraper-old12
-
- -
-
- -
- - + From 495cfca3b73dbba821ff7637f0d680347cd1beab Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:32:59 -0400 Subject: [PATCH 03/82] Add browser panel and sidebar layout design explorations - Add interactive HTML mockups comparing browser panel layouts and sidebar variations - Demonstrate expanded, collapsed, overlay, and deck-style sidebar states --- docs/design/browser-panel-layouts.html | 1325 +++++++++++++++++++ docs/design/sidebar-variations-codex.html | 1463 +++++++++++++++++++++ 2 files changed, 2788 insertions(+) create mode 100644 docs/design/browser-panel-layouts.html create mode 100644 docs/design/sidebar-variations-codex.html diff --git a/docs/design/browser-panel-layouts.html b/docs/design/browser-panel-layouts.html new file mode 100644 index 000000000..dcf2d306c --- /dev/null +++ b/docs/design/browser-panel-layouts.html @@ -0,0 +1,1325 @@ + + + + + Browser panel brainstorm — layout options + + + +

Browser panel: where does it dock?

+

+ 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. +

+ +
+ + + +
+

+

+ +
+ Left sidebar +
+ + + + +
+ +
+ +
+ + + + diff --git a/docs/design/sidebar-variations-codex.html b/docs/design/sidebar-variations-codex.html new file mode 100644 index 000000000..cead3ef83 --- /dev/null +++ b/docs/design/sidebar-variations-codex.html @@ -0,0 +1,1463 @@ + + + + + + + + +Sidebar Variations + + + + + + From 6290e92b68c2c13e688ca93e4b09433b546e1141 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:35:13 -0400 Subject: [PATCH 04/82] Show source control change counts in browser panel header --- docs/design/browser-panel-layouts.html | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/design/browser-panel-layouts.html b/docs/design/browser-panel-layouts.html index dcf2d306c..977e7e1e5 100644 --- a/docs/design/browser-panel-layouts.html +++ b/docs/design/browser-panel-layouts.html @@ -423,6 +423,16 @@ color: var(--blue); background: rgba(122, 162, 247, 0.12); } + .hbtn { + display: inline-flex; + align-items: center; + gap: 5px; + } + .hbtn .ds { + font-family: var(--mono); + font-size: 10px; + line-height: 1; + } .chat-scroll { flex: 1; overflow: hidden; @@ -1054,7 +1064,7 @@

Browser panel: where does it dock?

▸ 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 ( +
+ + + + + + + {needsUserCount > 0 ? ( + + + + + {needsUserCount} + + + + ) : null} + + {entries.length > 0 ? ( + <> +
+
+ {entries.map((entry) => ( + + ))} +
+ + ) : null} + +
+ + + +
+ ); +}); + +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}
Projects @@ -3089,18 +3037,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const generalChatsProject = useStore(selectGeneralChatsProjectAcrossEnvironments); - const activeEnvironmentId = useStore((state) => state.activeEnvironmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const generalChatsProjectRef = useMemo( - () => - resolveGeneralChatsProjectRef({ - generalChatsProject, - activeEnvironmentId, - primaryEnvironmentId, - }), - [activeEnvironmentId, generalChatsProject, primaryEnvironmentId], - ); const workspaceProjectsLength = projects.filter( (project) => project.kind !== "general-chat", ).length; @@ -3421,16 +3358,6 @@ export default function Sidebar() { ]); // Pinned above the Projects section, hidden until it has chats; discovery // happens through the header's new-general-chat button. - const generalChatsSnapshot = useMemo(() => { - const snapshot = sidebarProjects.find((project) => project.kind === "general-chat"); - if (!snapshot) { - return null; - } - const hasVisibleThreads = (threadsByProjectKey.get(snapshot.projectKey) ?? []).some( - (thread) => thread.archivedAt === null, - ); - return hasVisibleThreads ? snapshot : null; - }, [sidebarProjects, threadsByProjectKey]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; // On Deck: the cross-project working set pinned above the project browser. @@ -3507,10 +3434,7 @@ export default function Sidebar() { const visibleSidebarThreadKeys = useMemo(() => { const onDeckKeySet = new Set(onDeckVisibleThreadKeys); - const projectThreadKeys = [ - ...(generalChatsSnapshot ? [generalChatsSnapshot] : []), - ...sortedProjects, - ].flatMap((project) => { + const projectThreadKeys = sortedProjects.flatMap((project) => { const projectExpanded = projectExpandedById[project.projectKey] ?? true; if (!projectExpanded) { return []; @@ -3544,7 +3468,6 @@ export default function Sidebar() { return [...onDeckVisibleThreadKeys, ...projectThreadKeys]; }, [ activeRouteProjectKey, - generalChatsSnapshot, onDeckVisibleThreadKeys, routeThreadKey, sidebarThreadSortOrder, @@ -3604,6 +3527,26 @@ export default function Sidebar() { const visibleThreadJumpLabelByKey = showThreadJumpHints ? threadJumpLabelByKey : 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 destinations = useMemo( + () => [ + { + id: "chats", + label: "Chats", + icon: DESTINATION_ICONS.chats, + active: pathname.startsWith("/chats"), + onSelect: () => { + void navigate({ to: "/chats" }); + }, + }, + ], + [navigate, pathname], + ); + const destinationBand = useMemo( + () => , + [destinations], + ); const onDeckSection = useMemo( () => onDeckEntries.length > 0 ? ( @@ -3783,6 +3726,7 @@ export default function Sidebar() { , ); - return { expandSidebar, navigateToThread, screen }; + return { expandSidebar, navigateToThread, onSelectChats, screen }; } describe("DeckRail", () => { @@ -100,6 +111,16 @@ describe("DeckRail", () => { expect(expandSidebar).toHaveBeenCalledOnce(); }); + it("offers the same destinations the expanded pane does", async () => { + const { onSelectChats } = renderRail([]); + + const chats = page.getByTestId("deck-rail-destination-chats"); + await expect.element(chats).toHaveAttribute("aria-label", "Chats"); + + await chats.click(); + expect(onSelectChats).toHaveBeenCalledOnce(); + }); + 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 57d41af94..363ad1db0 100644 --- a/apps/web/src/components/sidebar/DeckRail.tsx +++ b/apps/web/src/components/sidebar/DeckRail.tsx @@ -7,6 +7,7 @@ import { countThreadsNeedingUser, isNeedsUserStatus } from "../Sidebar.logic"; import { ThreadStatusDot } from "../ThreadStatusIndicators"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { OnDeckEntry } from "./OnDeckSection"; +import type { SidebarDestination } from "./DestinationBand"; 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"; @@ -16,10 +17,18 @@ interface RailButtonProps { onClick: () => void; testId?: string; active?: boolean; + disabled?: boolean; children: React.ReactNode; } -function RailButton({ label, onClick, testId, active = false, children }: RailButtonProps) { +function RailButton({ + label, + onClick, + testId, + active = false, + disabled = false, + children, +}: RailButtonProps) { return ( {children} @@ -42,6 +56,7 @@ function RailButton({ label, onClick, testId, active = false, children }: RailBu export interface DeckRailProps { entries: readonly OnDeckEntry[]; + destinations: readonly SidebarDestination[]; routeThreadKey: string | null; navigateToThread: (threadRef: ScopedThreadRef) => void; openSearch: () => void; @@ -61,6 +76,7 @@ export interface DeckRailProps { export const DeckRail = memo(function DeckRail(props: DeckRailProps) { const { entries, + destinations, routeThreadKey, navigateToThread, openSearch, @@ -81,6 +97,26 @@ export const DeckRail = memo(function DeckRail(props: DeckRailProps) { + {destinations.length > 0 ? ( + <> +
+ {destinations.map((destination) => { + const Icon = destination.icon; + return ( + + + + ); + })} + + ) : null} {needsUserCount > 0 ? ( void; +} + +/** + * Fixed places in the app, pinned above the deck. + * + * On Deck changes height as threads come and go, so anything below it moves; + * navigation has to sit above it to stay in the same pixel every time. These + * rows are deliberately plain — one line, no counts, no disclosure — so they + * read as chrome and never compete with the live work underneath. + */ +export function DestinationBand({ destinations }: { destinations: readonly SidebarDestination[] }) { + if (destinations.length === 0) { + return null; + } + + return ( + + + {destinations.map((destination) => { + const Icon = destination.icon; + return ( + + + + {destination.label} + + + ); + })} + + + ); +} + +export const DESTINATION_ICONS = { + newChat: MessageCirclePlusIcon, + chats: MessagesSquareIcon, +} as const; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 6a043eb8e..262dcdc59 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as ChatChatsRouteImport } from './routes/_chat.chats' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -95,6 +96,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) +const ChatChatsRoute = ChatChatsRouteImport.update({ + id: '/chats', + path: '/chats', + getParentRoute: () => ChatRoute, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -111,6 +117,7 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/chats': typeof ChatChatsRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -126,6 +133,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/pair': typeof PairRoute + '/chats': typeof ChatChatsRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -145,6 +153,7 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/_chat/chats': typeof ChatChatsRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -165,6 +174,7 @@ export interface FileRouteTypes { | '/' | '/pair' | '/settings' + | '/chats' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -180,6 +190,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/pair' + | '/chats' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -198,6 +209,7 @@ export interface FileRouteTypes { | '/_chat' | '/pair' | '/settings' + | '/_chat/chats' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -319,6 +331,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/_chat/chats': { + id: '/_chat/chats' + path: '/chats' + fullPath: '/chats' + preLoaderRoute: typeof ChatChatsRouteImport + parentRoute: typeof ChatRoute + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -337,12 +356,14 @@ declare module '@tanstack/react-router' { } interface ChatRouteChildren { + ChatChatsRoute: typeof ChatChatsRoute ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute } const ChatRouteChildren: ChatRouteChildren = { + ChatChatsRoute: ChatChatsRoute, ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, diff --git a/apps/web/src/routes/_chat.chats.tsx b/apps/web/src/routes/_chat.chats.tsx new file mode 100644 index 000000000..be11c0dad --- /dev/null +++ b/apps/web/src/routes/_chat.chats.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ChatsDestinationView } from "../components/ChatsDestinationView"; + +export const Route = createFileRoute("/_chat/chats")({ + component: ChatsDestinationView, +}); diff --git a/docs/design/sidebar-destination-band.html b/docs/design/sidebar-destination-band.html new file mode 100644 index 000000000..683171f78 --- /dev/null +++ b/docs/design/sidebar-destination-band.html @@ -0,0 +1,628 @@ + + + + + Sidebar brainstorm — destination band + + + +

Sidebar: a fixed destination band above On Deck

+

+ 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. +

+
+ +
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. +

+
+ +
+
+ + + + 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. +

+
+ +
+
+ + + + 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. */} + + +

+ {thread.title} +

+
+ + + {status ? status.label : "Idle"} + + + {formatRelativeTimeLabel(activityAt)} + +
+
+ {project ? ( + } + > + {project.name} + + ) : null} + {environmentLabel ? ( + }>{environmentLabel} + ) : null} + {thread.branch || worktreeName ? ( + }> + {thread.branch ?? worktreeName} + {worktreeName && thread.branch ? ( + worktree + ) : null} + + ) : null} + {ProviderIcon || modelLabel || providerLabel ? ( + : null}> + {modelLabel ?? providerLabel} + + ) : null} +
+
+
+ ); +} 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.

) : ( -
- {chats.map((thread) => ( - - - - ))} -
+ + + ))} +
+ )}
); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 777a9f1cf..0ffb84127 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2912,6 +2912,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( {destinationBand} + {onDeckSection}
diff --git a/apps/web/src/components/sidebar/DeckRail.tsx b/apps/web/src/components/sidebar/DeckRail.tsx index 4db66236c..64546beac 100644 --- a/apps/web/src/components/sidebar/DeckRail.tsx +++ b/apps/web/src/components/sidebar/DeckRail.tsx @@ -8,7 +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"; +import { ThreadHoverCard, ThreadHoverCardGroup } 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"; @@ -136,16 +136,18 @@ export const DeckRail = memo(function DeckRail(props: DeckRailProps) { {entries.length > 0 ? ( <>
-
- {entries.map((entry) => ( - - ))} -
+ +
+ {entries.map((entry) => ( + + ))} +
+
) : null} diff --git a/apps/web/src/components/sidebar/DestinationBand.tsx b/apps/web/src/components/sidebar/DestinationBand.tsx index 8199cbe3a..6de5e96ba 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; @@ -38,7 +38,7 @@ export function DestinationBand({ destinations }: { destinations: readonly Sideb disabled={destination.disabled ?? false} data-testid={`sidebar-destination-${destination.id}`} className={cn( - "gap-2 px-2 py-1.5 text-muted-foreground/80 hover:bg-accent hover:text-foreground", + "gap-1.5 px-2 py-1.5 text-muted-foreground/80 hover:bg-accent hover:text-foreground", destination.active && "text-foreground", )} onClick={destination.onSelect} diff --git a/apps/web/src/components/sidebar/OnDeckSection.tsx b/apps/web/src/components/sidebar/OnDeckSection.tsx index 086eca4ec..68b205789 100644 --- a/apps/web/src/components/sidebar/OnDeckSection.tsx +++ b/apps/web/src/components/sidebar/OnDeckSection.tsx @@ -8,7 +8,7 @@ import { resolveThreadRowClassName, type ThreadStatusPill } from "../Sidebar.log import { ThreadStatusLabel } from "../ThreadStatusIndicators"; import { SectionLabel } from "../ui/threadline"; import { ProjectFavicon } from "../ProjectFavicon"; -import { ThreadHoverCard } from "./ThreadHoverCard"; +import { ThreadHoverCard, ThreadHoverCardGroup } from "./ThreadHoverCard"; import { SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -178,23 +178,25 @@ 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/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 (
- Recent threads + {scope === "chats" ? "Recent chats" : "Recent threads"}
{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

+ } + /> + {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.length} {chats.length === 1 ? "chat" : "chats"} +
{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() { ))}
-
+ )}
); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 3e0636a36..63aff431f 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -3,6 +3,7 @@ import { ProviderDriverKind } from "@threadlines/contracts"; import { buildOnDeckSyncInput, + buildProjectHoverSummary, countThreadsNeedingUser, createThreadJumpHintVisibilityController, excludeOnDeckThreads, @@ -37,6 +38,7 @@ import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, + type SidebarThreadSummary, type Thread, } from "../types"; @@ -1315,3 +1317,66 @@ describe("on deck classification", () => { expect(countThreadsNeedingUser([])).toBe(0); }); }); + +describe("buildProjectHoverSummary", () => { + const thread = (id: string, overrides: Partial = {}) => + ({ + id: ThreadId.make(id), + environmentId: localEnvironmentId, + projectId: ProjectId.make("project-badcode"), + title: id, + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + createdAt: "2026-07-20T00:00:00.000Z", + archivedAt: null, + pinnedAt: null, + latestTurn: null, + branch: null, + worktreePath: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }) as SidebarThreadSummary; + + const summaryFor = (threads: readonly SidebarThreadSummary[]) => + buildProjectHoverSummary({ + name: "badcode", + cwd: "/repo/badcode", + environmentId: localEnvironmentId, + threads, + getLastVisitedAt: () => undefined, + }); + + it("counts only threads the provider is still live on", () => { + const summary = summaryFor([ + thread("waiting", { hasPendingApprovals: true }), + thread("idle"), + thread("also-idle"), + ]); + + expect(summary.threadCount).toBe(3); + expect(summary.activeCount).toBe(1); + expect(summary.status?.label).toBe("Pending Approval"); + }); + + it("reports the most recent activity across the project", () => { + const summary = summaryFor([ + thread("older", { latestUserMessageAt: "2026-07-21T00:00:00.000Z" }), + thread("newest", { latestUserMessageAt: "2026-07-24T00:00:00.000Z" }), + thread("middle", { latestUserMessageAt: "2026-07-22T00:00:00.000Z" }), + ]); + + expect(summary.lastActivityAt).toBe("2026-07-24T00:00:00.000Z"); + }); + + it("describes an empty project without inventing activity", () => { + const summary = summaryFor([]); + + expect(summary.threadCount).toBe(0); + expect(summary.activeCount).toBe(0); + expect(summary.status).toBeNull(); + expect(summary.lastActivityAt).toBeNull(); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 89cff7cc4..6840b6f30 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -9,6 +9,8 @@ import { toSortableTimestamp, type ThreadSortInput, } from "../lib/threadSort"; +import type { EnvironmentId } from "@threadlines/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@threadlines/client-runtime"; import type { SidebarThreadSummary, Thread } from "../types"; import type { OnDeckSyncInput } from "../uiStateStore"; import { cn } from "../lib/utils"; @@ -478,6 +480,11 @@ const NEEDS_USER_STATUSES: ReadonlySet = new Set([ "Awaiting Input", ]); +/** True while the provider is working on the thread or waiting on the user. */ +export function isLiveThreadStatus(status: ThreadStatusPill | null): boolean { + return status !== null && ON_DECK_LIVE_STATUSES.has(status.label); +} + /** 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); @@ -717,3 +724,51 @@ export function sortScopedProjectsByActivity< return project ? [project] : []; }); } + +export interface ProjectHoverSummary { + name: string; + cwd: string; + environmentId: EnvironmentId; + status: ThreadStatusPill | null; + threadCount: number; + activeCount: number; + lastActivityAt: string | null; +} + +/** + * Builds a project's hover summary from its threads. Shared so the expanded row + * and the collapsed rail glyph describe a project identically. + */ +export function buildProjectHoverSummary(input: { + name: string; + cwd: string; + environmentId: EnvironmentId; + threads: readonly SidebarThreadSummary[]; + getLastVisitedAt: (threadKey: string) => string | null | undefined; +}): ProjectHoverSummary { + const getThreadKey = (thread: SidebarThreadSummary) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const statuses = input.threads.map((thread) => { + const lastVisitedAt = input.getLastVisitedAt(getThreadKey(thread)); + return resolveThreadStatusPill({ + thread: { + ...thread, + ...(lastVisitedAt !== undefined && lastVisitedAt !== null ? { lastVisitedAt } : {}), + }, + }); + }); + const lastActivityAt = input.threads.reduce((latest, thread) => { + const at = thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt; + return latest === null || at > latest ? at : latest; + }, null); + + return { + name: input.name, + cwd: input.cwd, + environmentId: input.environmentId, + status: resolveProjectStatusIndicator(statuses), + threadCount: input.threads.length, + activeCount: statuses.filter(isLiveThreadStatus).length, + lastActivityAt, + }; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fd4f0f7b0..add655bae 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -156,6 +156,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { buildOnDeckSyncInput, + buildProjectHoverSummary, excludeOnDeckThreads, getSidebarThreadIdsToPrewarm, getSidebarThreadWindow, @@ -176,6 +177,7 @@ import { } from "./Sidebar.logic"; import { OnDeckSection, type OnDeckEntry } from "./sidebar/OnDeckSection"; import { DeckRail, type DeckRailProject } from "./sidebar/DeckRail"; +import { ProjectHoverCard } from "./sidebar/ProjectHoverCard"; import { DESTINATION_ICONS, DestinationBand, @@ -1265,30 +1267,35 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return counts; }, [memberProjectByScopedKey, project.memberProjects, projectThreads]); - const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => { - const lastVisitedAtByThreadKey = new Map( - projectThreads.map((thread, index) => [ - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - threadLastVisitedAts[index] ?? null, - ]), - ); - const visibleProjectThreads = sortThreads( - projectThreads.filter((thread) => thread.archivedAt === null), - threadSortOrder, - ); - 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)), - ), - projectStatus, - visibleProjectThreads, - }; - }, [projectThreads, threadLastVisitedAts, threadSortOrder]); + const { hoverSummary, projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = + useMemo(() => { + const lastVisitedAtByThreadKey = new Map( + projectThreads.map((thread, index) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + threadLastVisitedAts[index] ?? null, + ]), + ); + const visibleProjectThreads = sortThreads( + projectThreads.filter((thread) => thread.archivedAt === null), + threadSortOrder, + ); + const hoverSummary = buildProjectHoverSummary({ + name: project.displayName, + cwd: project.cwd, + environmentId: project.environmentId, + threads: visibleProjectThreads, + getLastVisitedAt: (threadKey) => lastVisitedAtByThreadKey.get(threadKey), + }); + const projectStatus = hoverSummary.status; + return { + orderedProjectThreadKeys: visibleProjectThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + hoverSummary, + projectStatus, + visibleProjectThreads, + }; + }, [projectThreads, threadLastVisitedAts, threadSortOrder]); const { hiddenThreadStatus, @@ -2197,121 +2204,123 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec {/* Sticky: stays pinned to the sidebar viewport top while this project's threads scroll, so context and the collapse toggle are always in reach. The next project header pushes it off naturally. */} -
- - {!projectExpanded && projectStatus ? ( -
+
+ sortedProjects.map((project) => ({ projectKey: project.projectKey, - name: project.displayName, - cwd: project.cwd, - environmentId: project.environmentId, - status: resolveProjectStatusForThreads({ + summary: buildProjectHoverSummary({ + name: project.displayName, + cwd: project.cwd, + environmentId: project.environmentId, threads: (threadsByProjectKey.get(project.projectKey) ?? []).filter( (thread) => thread.archivedAt === null, ), - getThreadKey: (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), getLastVisitedAt: (threadKey) => threadLastVisitedAtById[threadKey], }), })), diff --git a/apps/web/src/components/sidebar/DeckRail.browser.tsx b/apps/web/src/components/sidebar/DeckRail.browser.tsx index d5014b7ef..ec0f965da 100644 --- a/apps/web/src/components/sidebar/DeckRail.browser.tsx +++ b/apps/web/src/components/sidebar/DeckRail.browser.tsx @@ -150,16 +150,26 @@ describe("DeckRail", () => { projects: [ { projectKey: "badcode", - name: "badcode", - cwd: "/repo/badcode", - environmentId: ENVIRONMENT_ID, - status: status("Working"), + summary: { + name: "badcode", + cwd: "/repo/badcode", + environmentId: ENVIRONMENT_ID, + status: status("Working"), + threadCount: 3, + activeCount: 1, + lastActivityAt: "2026-07-26T00:00:00.000Z", + }, }, ], }); const glyph = page.getByTestId("deck-rail-project-badcode"); - await expect.element(glyph).toHaveAttribute("aria-label", "badcode · Working"); + await expect.element(glyph).toHaveAttribute("aria-label", "badcode"); + + // The counts a favicon cannot carry live in the hover card. + await glyph.hover(); + await expect.element(page.getByTestId("project-hover-card")).toHaveTextContent("3 threads"); + await expect.element(page.getByTestId("project-hover-card")).toHaveTextContent("1 active"); await glyph.click(); expect(onRevealProject).toHaveBeenCalledWith("badcode"); diff --git a/apps/web/src/components/sidebar/DeckRail.tsx b/apps/web/src/components/sidebar/DeckRail.tsx index 77eeba8ff..4c3d7a381 100644 --- a/apps/web/src/components/sidebar/DeckRail.tsx +++ b/apps/web/src/components/sidebar/DeckRail.tsx @@ -13,7 +13,9 @@ import { ProjectFavicon } from "../ProjectFavicon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { OnDeckEntry } from "./OnDeckSection"; import type { SidebarDestination } from "./DestinationBand"; -import { ThreadHoverCard, ThreadHoverCardGroup } from "./ThreadHoverCard"; +import { ThreadHoverCard } from "./ThreadHoverCard"; +import { SidebarHoverCardGroup } from "./hoverCard"; +import { ProjectHoverCard, type ProjectHoverSummary } from "./ProjectHoverCard"; 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"; @@ -62,10 +64,7 @@ function RailButton({ export interface DeckRailProject { projectKey: string; - name: string; - cwd: string; - environmentId: EnvironmentId; - status: ThreadStatusPill | null; + summary: ProjectHoverSummary; } export interface DeckRailProps { @@ -153,7 +152,7 @@ export const DeckRail = memo(function DeckRail(props: DeckRailProps) { {entries.length > 0 ? ( <>
- +
{entries.map((entry) => ( ))}
-
+ ) : null} @@ -236,36 +235,31 @@ function DeckRailProjectGlyph({ const handleClick = useCallback(() => { onRevealProject(project.projectKey); }, [onRevealProject, project.projectKey]); - const label = project.status ? `${project.name} · ${project.status.label}` : project.name; + const { summary } = project; 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/components/sidebar/OnDeckSection.tsx b/apps/web/src/components/sidebar/OnDeckSection.tsx index 68b205789..a8920892f 100644 --- a/apps/web/src/components/sidebar/OnDeckSection.tsx +++ b/apps/web/src/components/sidebar/OnDeckSection.tsx @@ -8,7 +8,8 @@ import { resolveThreadRowClassName, type ThreadStatusPill } from "../Sidebar.log import { ThreadStatusLabel } from "../ThreadStatusIndicators"; import { SectionLabel } from "../ui/threadline"; import { ProjectFavicon } from "../ProjectFavicon"; -import { ThreadHoverCard, ThreadHoverCardGroup } from "./ThreadHoverCard"; +import { ThreadHoverCard } from "./ThreadHoverCard"; +import { SidebarHoverCardGroup } from "./hoverCard"; import { SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -178,7 +179,7 @@ export const OnDeckSection = memo(function OnDeckSection(props: OnDeckSectionPro
On deck
- + {entries.map((entry) => { const threadKey = scopedThreadKey( @@ -196,7 +197,7 @@ export const OnDeckSection = memo(function OnDeckSection(props: OnDeckSectionPro ); })} - + ); }); diff --git a/apps/web/src/components/sidebar/ProjectHoverCard.tsx b/apps/web/src/components/sidebar/ProjectHoverCard.tsx new file mode 100644 index 000000000..c6887b1a9 --- /dev/null +++ b/apps/web/src/components/sidebar/ProjectHoverCard.tsx @@ -0,0 +1,91 @@ +import { FolderIcon, MessagesSquareIcon, ServerIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { usePrimaryEnvironmentId } from "../../environments/primary"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../../environments/runtime"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import type { ProjectHoverSummary } from "../Sidebar.logic"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + HOVER_CARD_POPUP_CLASS_NAME, + HoverCardDetailRow, + HoverCardDetails, + HoverCardStatusLine, + HoverCardTitle, +} from "./hoverCard"; + +/** + * What a project row leaves out. + * + * A row shows a name and a count; the rail shows only a favicon. Neither says + * where the project lives on disk, how much of it is moving right now, or when + * it last did anything, which is what you actually want before opening it. + */ +export function ProjectHoverCard({ + project, + side = "right", + children, +}: { + project: ProjectHoverSummary; + side?: "right" | "left" | "top" | "bottom"; + children: ReactNode; +}) { + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const isRemote = primaryEnvironmentId !== null && project.environmentId !== primaryEnvironmentId; + const runtimeLabel = useSavedEnvironmentRuntimeStore( + (state) => state.byId[project.environmentId]?.descriptor?.label ?? null, + ); + const savedLabel = useSavedEnvironmentRegistryStore( + (state) => state.byId[project.environmentId]?.label ?? null, + ); + const environmentLabel = isRemote ? (runtimeLabel ?? savedLabel ?? "Remote") : null; + + const threadsLabel = project.threadCount === 1 ? "1 thread" : `${project.threadCount} threads`; + const countsLabel = + project.activeCount > 0 ? `${threadsLabel} · ${project.activeCount} active` : threadsLabel; + + return ( + + + + {project.name} + + + }> + {countsLabel} + + {environmentLabel ? ( + }> + {environmentLabel} + + ) : null} + {/* Paths truncate at the wrong end by default: the leaf directory is + the identifying part, so keep the tail visible. */} + } + className="truncate text-left [direction:rtl]" + > + {project.cwd} + + + + + ); +} + +export type { ProjectHoverSummary }; diff --git a/apps/web/src/components/sidebar/ThreadHoverCard.tsx b/apps/web/src/components/sidebar/ThreadHoverCard.tsx index 631c6b6d3..4bb490aa3 100644 --- a/apps/web/src/components/sidebar/ThreadHoverCard.tsx +++ b/apps/web/src/components/sidebar/ThreadHoverCard.tsx @@ -15,19 +15,14 @@ import { formatRelativeTimeLabel } from "../../timestampFormat"; import type { SidebarThreadSummary } from "../../types"; import type { ThreadStatusPill } from "../Sidebar.logic"; import { ProjectFavicon } from "../ProjectFavicon"; -import { ThreadStatusDot } from "../ThreadStatusIndicators"; -import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; - -function DetailRow({ icon, children }: { icon: ReactNode; children: ReactNode }) { - return ( -
- - {icon} - - {children} -
- ); -} +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + HOVER_CARD_POPUP_CLASS_NAME, + HoverCardDetailRow, + HoverCardDetails, + HoverCardStatusLine, + HoverCardTitle, +} from "./hoverCard"; /** * The details a truncated row had to drop. @@ -94,63 +89,39 @@ export function ThreadHoverCard({ side={side} sideOffset={8} align="start" - className="w-64 rounded-lg p-3 text-left text-popover-foreground text-sm shadow-none elevate-popover" + className={HOVER_CARD_POPUP_CLASS_NAME} data-testid="thread-hover-card" > -

- {thread.title} -

-
- - - {status ? status.label : "Idle"} - - - {formatRelativeTimeLabel(activityAt)} - -
-
+ {thread.title} + + {project ? ( - } > {project.name} - + ) : null} {environmentLabel ? ( - }>{environmentLabel} + }> + {environmentLabel} + ) : null} {thread.branch || worktreeName ? ( - }> + }> {thread.branch ?? worktreeName} {worktreeName && thread.branch ? ( worktree ) : null} - + ) : null} {ProviderIcon || modelLabel || providerLabel ? ( - : null}> + : null}> {modelLabel ?? providerLabel} - + ) : 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/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 ( +
+ + + {status ? status.label : idleLabel} + + {timestamp ? ( + {timestamp} + ) : null} +
+ ); +} + +export function HoverCardDetailRow({ + icon, + children, + className, +}: { + icon: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( +
+ + {icon} + + {children} +
+ ); +} + +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 ( -
- - - - - - - {destinations.length > 0 ? ( - <> -
- {destinations.map((destination) => { - const Icon = destination.icon; - return ( - - - - ); - })} - - ) : null} - {needsUserCount > 0 ? ( - - - - - {needsUserCount} - - + +
+ + - ) : null} + + + + {destinations.length > 0 ? ( + <> +
+ {destinations.map((destination) => { + const Icon = destination.icon; + return ( + + + + ); + })} + + ) : null} + {needsUserCount > 0 ? ( + + + + + {needsUserCount} + + + + ) : null} - {entries.length > 0 ? ( - <> -
- + {entries.length > 0 ? ( + <> +
{entries.map((entry) => ( ))}
- - - ) : null} + + ) : null} - {projects.length > 0 ? ( - <> -
-
- {projects.map((project) => ( - - ))} -
- - ) : null} + {projects.length > 0 ? ( + <> +
+
+ {projects.map((project) => ( + + ))} +
+ + ) : null} -
- - - -
+
+ + + +
+ ); }); 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 */}
{/* Chat column */} -
+
{/* Messages Wrapper */}
{/* Messages — LegendList handles virtualization and scrolling internally */} @@ -6370,6 +6399,16 @@ export default function ChatView(props: ChatViewProps) { ) : null}
{/* end chat column */} + {browserOpen && routeThreadRef !== null ? ( + <> + + + + ) : null}
{/* end horizontal flex container */} diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx new file mode 100644 index 000000000..c41725cd8 --- /dev/null +++ b/apps/web/src/components/browser/BrowserPanel.tsx @@ -0,0 +1,243 @@ +import type { ScopedThreadRef } from "@threadlines/contracts"; +import { ArrowLeftIcon, ArrowRightIcon, ExternalLinkIcon, RotateCwIcon, XIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + BROWSER_VIEWPORT_PRESETS, + selectThreadBrowserState, + useBrowserPanelStore, + type BrowserViewportPresetId, +} from "../../browserPanelStore"; +import { isElectron } from "../../env"; +import { cn } from "../../lib/utils"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { normalizePreviewUrl } from "./previewUrl"; + +/** + * Electron's is a custom element, so React needs to be told it exists. + * It is deliberately the renderer's own element rather than a native view over + * the window: that keeps it inside normal CSS layout, so dialogs, popovers and + * the source control sheet stack above it without any bounds bookkeeping. + */ +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + webview: React.DetailedHTMLProps, HTMLElement> & { + src?: string; + partition?: string; + }; + } + } +} + +interface PreviewWebview extends HTMLElement { + getURL: () => string; + canGoBack: () => boolean; + canGoForward: () => boolean; + goBack: () => void; + goForward: () => void; + reload: () => void; + loadURL: (url: string) => Promise; +} + +export function BrowserPanel({ + threadRef, + flexGrow, + onClose, +}: { + threadRef: ScopedThreadRef; + /** Complement of the chat column's share, so the two honour one ratio. */ + flexGrow: number; + onClose: () => void; +}) { + const browserState = useBrowserPanelStore((store) => + selectThreadBrowserState(store.browserStateByThreadKey, threadRef), + ); + const setBrowserUrl = useBrowserPanelStore((store) => store.setBrowserUrl); + const setViewportPreset = useBrowserPanelStore((store) => store.setViewportPreset); + + const webviewRef = useRef(null); + const [addressDraft, setAddressDraft] = useState(browserState.url ?? ""); + const [isLoading, setIsLoading] = useState(false); + const [navState, setNavState] = useState({ canGoBack: false, canGoForward: false }); + + // The address bar is an input the user types in, so it must not be yanked + // back to the store on every keystroke -- only when the page itself moves. + useEffect(() => { + setAddressDraft(browserState.url ?? ""); + }, [browserState.url]); + + useEffect(() => { + const webview = webviewRef.current; + if (webview === null) { + return; + } + const syncNav = () => { + setNavState({ canGoBack: webview.canGoBack(), canGoForward: webview.canGoForward() }); + }; + const onNavigated = () => { + syncNav(); + setBrowserUrl(threadRef, webview.getURL()); + }; + const onStart = () => setIsLoading(true); + const onStop = () => { + setIsLoading(false); + syncNav(); + }; + webview.addEventListener("did-navigate", onNavigated); + webview.addEventListener("did-navigate-in-page", onNavigated); + webview.addEventListener("did-start-loading", onStart); + webview.addEventListener("did-stop-loading", onStop); + return () => { + webview.removeEventListener("did-navigate", onNavigated); + webview.removeEventListener("did-navigate-in-page", onNavigated); + webview.removeEventListener("did-start-loading", onStart); + webview.removeEventListener("did-stop-loading", onStop); + }; + }, [setBrowserUrl, threadRef]); + + const submitAddress = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + const normalized = normalizePreviewUrl(addressDraft); + if (normalized === null) { + return; + } + setBrowserUrl(threadRef, normalized); + void webviewRef.current?.loadURL(normalized); + }, + [addressDraft, setBrowserUrl, threadRef], + ); + + const preset = + BROWSER_VIEWPORT_PRESETS.find((entry) => entry.id === browserState.viewportPresetId) ?? + BROWSER_VIEWPORT_PRESETS[0]; + + return ( +
+
+ webviewRef.current?.goBack()} + > + + + webviewRef.current?.goForward()} + > + + + webviewRef.current?.reload()}> + + + +
+ setAddressDraft(event.target.value)} + /> +
+ + + + + + +
+ +
+ {isElectron ? ( + + ) : ( + + )} +
+
+ ); +} + +function NavButton({ + label, + disabled, + onClick, + children, +}: { + label: string; + disabled?: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + + } + > + {children} + + {label} + + ); +} + +/** + * 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. +

+
+ ); +} diff --git a/apps/web/src/components/browser/BrowserSplitHandle.tsx b/apps/web/src/components/browser/BrowserSplitHandle.tsx new file mode 100644 index 000000000..d46697dbe --- /dev/null +++ b/apps/web/src/components/browser/BrowserSplitHandle.tsx @@ -0,0 +1,83 @@ +import { useCallback, useRef } from "react"; + +import { clampBrowserSplitFraction } from "../../browserPanelStore"; + +/** + * Drag handle between the chat column and the browser panel. + * + * Reports a fraction rather than a width so the split survives window resizing: + * a stored pixel width would leave the chat column the wrong size on a + * different display. The store clamps, so a hard drag cannot collapse a pane. + */ +export function BrowserSplitHandle({ + chatFraction, + onChange, +}: { + chatFraction: number; + onChange: (fraction: number) => void; +}) { + const draggingRef = useRef(false); + + const handlePointerDown = useCallback((event: React.PointerEvent) => { + draggingRef.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + }, []); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (!draggingRef.current) { + return; + } + const row = event.currentTarget.parentElement; + if (row === null) { + return; + } + const bounds = row.getBoundingClientRect(); + if (bounds.width === 0) { + return; + } + onChange(clampBrowserSplitFraction((event.clientX - bounds.left) / bounds.width)); + }, + [onChange], + ); + + const endDrag = useCallback((event: React.PointerEvent) => { + draggingRef.current = false; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }, []); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const step = event.shiftKey ? 0.1 : 0.02; + if (event.key === "ArrowLeft") { + event.preventDefault(); + onChange(clampBrowserSplitFraction(chatFraction - step)); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + onChange(clampBrowserSplitFraction(chatFraction + step)); + } + }, + [chatFraction, onChange], + ); + + return ( +
+ ); +} diff --git a/apps/web/src/components/browser/previewUrl.test.ts b/apps/web/src/components/browser/previewUrl.test.ts new file mode 100644 index 000000000..35a7fcacd --- /dev/null +++ b/apps/web/src/components/browser/previewUrl.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizePreviewUrl } from "./previewUrl"; + +describe("normalizePreviewUrl", () => { + it("assumes http for a bare dev-server address", () => { + // https would fail on every dev server without a certificate. + expect(normalizePreviewUrl("localhost:5173")).toBe("http://localhost:5173/"); + expect(normalizePreviewUrl("127.0.0.1:3000/pricing")).toBe("http://127.0.0.1:3000/pricing"); + }); + + it("keeps an explicit scheme", () => { + expect(normalizePreviewUrl("https://example.com/a")).toBe("https://example.com/a"); + }); + + it("rejects entries that would navigate somewhere surprising", () => { + expect(normalizePreviewUrl("")).toBeNull(); + expect(normalizePreviewUrl(" ")).toBeNull(); + expect(normalizePreviewUrl("javascript:alert(1)")).toBeNull(); + expect(normalizePreviewUrl("file:///etc/passwd")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/browser/previewUrl.ts b/apps/web/src/components/browser/previewUrl.ts new file mode 100644 index 000000000..ef0b09d4d --- /dev/null +++ b/apps/web/src/components/browser/previewUrl.ts @@ -0,0 +1,33 @@ +/** + * Turns whatever was typed in the address bar into something loadable. + * + * Deliberately permissive about scheme and strict about everything else: the + * common case is a dev server typed as `localhost:5173`, which is not a valid + * URL until it has a scheme, while a genuinely malformed entry should fail + * quietly rather than navigate somewhere surprising. + */ +export function normalizePreviewUrl(input: string): string | null { + const trimmed = input.trim(); + if (trimmed === "") { + return null; + } + + const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) + ? trimmed + : // A bare host:port or path is http, not https: dev servers rarely have a + // certificate, and defaulting to https would fail on every one of them. + `http://${trimmed.replace(/^\/+/, "")}`; + + try { + const url = new URL(candidate); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + if (url.hostname === "") { + return null; + } + return url.toString(); + } catch { + return null; + } +} diff --git a/apps/web/src/components/chat/ChatHeader.render.test.tsx b/apps/web/src/components/chat/ChatHeader.render.test.tsx index 2a4f725e5..8867e1adf 100644 --- a/apps/web/src/components/chat/ChatHeader.render.test.tsx +++ b/apps/web/src/components/chat/ChatHeader.render.test.tsx @@ -26,6 +26,8 @@ function renderChatHeader(overrides: Partial> sourceControlToggleShortcutLabel: null, sourceControlOpen: false, sourceControlAvailable: false, + browserAvailable: true, + browserOpen: false, workingTreeDiffStat: null, fileBrowserAvailable: false, taskProgress: null, @@ -41,6 +43,7 @@ function renderChatHeader(overrides: Partial> onOpenForkSourceThread: vi.fn(), onToggleTerminal: vi.fn(), onToggleSourceControl: vi.fn(), + onToggleBrowser: vi.fn(), ...overrides, } satisfies ComponentProps; diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 10bdbabd0..39ce19b0a 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -6,7 +6,13 @@ import { type ResolvedKeybindingsConfig, } from "@threadlines/contracts"; import { memo } from "react"; -import { FolderInputIcon, FolderOpenIcon, GitForkIcon, TerminalSquareIcon } from "lucide-react"; +import { + FolderInputIcon, + FolderOpenIcon, + GitForkIcon, + GlobeIcon, + TerminalSquareIcon, +} from "lucide-react"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Group } from "../ui/group"; @@ -49,6 +55,9 @@ interface ChatHeaderProps { sourceControlOpen: boolean; /** False for capability-gated threads (General Chats) even when a project name exists. */ sourceControlAvailable: boolean; + /** False where there is no project to preview, e.g. a general chat. */ + browserAvailable: boolean; + browserOpen: 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 @@ -73,6 +82,7 @@ interface ChatHeaderProps { onOpenForkSourceThread: (threadId: ThreadId) => void; onToggleTerminal: () => void; onToggleSourceControl: () => void; + onToggleBrowser: () => void; /** Present only for General Chat threads that can continue into a project. */ onContinueInProject?: ((event: React.MouseEvent) => void) | undefined; continueInProjectDisabledReason?: string | null; @@ -118,6 +128,9 @@ export const ChatHeader = memo(function ChatHeader({ sourceControlToggleShortcutLabel, sourceControlOpen, sourceControlAvailable, + browserAvailable, + browserOpen, + onToggleBrowser, workingTreeDiffStat, fileBrowserAvailable, taskProgress, @@ -307,6 +320,25 @@ export const ChatHeader = memo(function ChatHeader({ : "Toggle terminal drawer"} + {browserAvailable ? ( + + + + + } + /> + Toggle browser preview + + ) : null} {sourceControlAvailable || sourceControlOpen ? ( Date: Sun, 26 Jul 2026 13:17:21 -0400 Subject: [PATCH 21/82] Run the preview in its own browser session, and enforce it Verifying the webview in the desktop app showed it was running in the app's default session: shared cookies and storage with Threadlines itself, which is the isolation the panel was supposed to have. The cause was assigning params.partition in will-attach-webview. Electron resolves the guest's session from the element's attribute before that event fires, so the assignment silently did nothing while reading as though it had. The renderer now sets the attribute, and the main process refuses any attach that asks for a different partition -- enforcement rather than assignment, so the attribute is load-bearing instead of advisory. The constant is shared because both ends have to agree on it. Verified in the running desktop app: the preview loads a real page and reports its own partition directory, and a webview requesting another partition does not attach. --- apps/desktop/src/preview/PreviewSession.ts | 3 ++- apps/desktop/src/window/DesktopWindow.ts | 20 +++++++++++++------ .../src/components/browser/BrowserPanel.tsx | 3 +++ packages/shared/package.json | 4 ++++ packages/shared/src/preview.ts | 9 +++++++++ 5 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 packages/shared/src/preview.ts diff --git a/apps/desktop/src/preview/PreviewSession.ts b/apps/desktop/src/preview/PreviewSession.ts index e27e748b9..66a917e87 100644 --- a/apps/desktop/src/preview/PreviewSession.ts +++ b/apps/desktop/src/preview/PreviewSession.ts @@ -15,8 +15,9 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; import { session, type Session } from "electron"; +import { PREVIEW_PARTITION } from "@threadlines/shared/preview"; -export const PREVIEW_PARTITION = "persist:threadlines-preview"; +export { PREVIEW_PARTITION }; /** * Permissions preview content may use. Deliberately short: a page under diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b4db0fdb4..6a848989c 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -1,5 +1,5 @@ import type { DesktopMenuActionPayload } from "@threadlines/contracts"; -import { PREVIEW_PARTITION } from "../preview/PreviewSession.ts"; +import { PREVIEW_PARTITION } from "@threadlines/shared/preview"; import { fromJsonStringPretty } from "@threadlines/shared/schemaJson"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; @@ -332,15 +332,23 @@ const make = Effect.gen(function* () { }, }); - // 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) => { + // Preview content is untrusted: no Node, no custom preload, and it must run + // in the preview partition rather than the app's own session. + // + // The partition is enforced, not assigned. Electron resolves the guest's + // session from the element's `partition` attribute before this fires, so + // assigning `params.partition` here silently does nothing and the guest + // would inherit the default session -- sharing cookies with Threadlines. + // Refusing the attach is the only way to make the renderer's attribute + // load-bearing. + 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 (params.partition !== PREVIEW_PARTITION) { + event.preventDefault(); + } }); if (Option.isSome(persistedWindowState) && persistedWindowState.value.isMaximized) { diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx index c41725cd8..9b5ea0301 100644 --- a/apps/web/src/components/browser/BrowserPanel.tsx +++ b/apps/web/src/components/browser/BrowserPanel.tsx @@ -11,6 +11,8 @@ import { import { isElectron } from "../../env"; import { cn } from "../../lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { PREVIEW_PARTITION } from "@threadlines/shared/preview"; + import { normalizePreviewUrl } from "./previewUrl"; /** @@ -183,6 +185,7 @@ export function BrowserPanel({ ? undefined : { width: `${preset.width}px`, height: `${preset.height}px`, flex: "none" } } + partition={PREVIEW_PARTITION} {...(browserState.url ? { src: browserState.url } : {})} /> ) : ( diff --git a/packages/shared/package.json b/packages/shared/package.json index 2c84bacc7..f0564dfe5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,6 +7,10 @@ "types": "./src/model.ts", "import": "./src/model.ts" }, + "./preview": { + "types": "./src/preview.ts", + "import": "./src/preview.ts" + }, "./advertisedEndpoint": { "types": "./src/advertisedEndpoint.ts", "import": "./src/advertisedEndpoint.ts" diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts new file mode 100644 index 000000000..b0d01680e --- /dev/null +++ b/packages/shared/src/preview.ts @@ -0,0 +1,9 @@ +/** + * The browser session the in-app preview runs in. + * + * Shared because both ends have to agree: the renderer sets it as the + * 's partition attribute, and the main process refuses to attach a + * webview asking for anything else. Preview content is untrusted and has no + * business sharing cookies with Threadlines itself. + */ +export const PREVIEW_PARTITION = "persist:threadlines-preview"; From 4ee71fd4e0447a8b8649851b668abaebadf4d2ae Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:24:02 -0400 Subject: [PATCH 22/82] Attach CDP to preview tabs and collect their diagnostics Chrome DevTools Protocol rather than executeJavaScript: it operates at the browser level instead of inside the page's JavaScript sandbox, so it is unaffected by same-origin policy, and the input events it will later dispatch are real ones -- hover, focus and blur behave as they do for a person, which synthetic DOM events do not. Console output and failed requests are collected from the moment a tab attaches rather than gathered on request, because by the time anyone asks what went wrong the interesting line has already been printed. Both buffers reset on main-frame navigation so they describe the page being looked at, and are capped so a chatty page cannot grow without bound. Tabs are addressed by webContents id. The renderer owns the and is the only side that knows which element belongs to which thread, so it registers the pairing and the main process stays an executor that cannot act on a tab nobody claimed. Verified in the running desktop app: attach succeeds, document.title and element text read back through CDP, a console log and error are captured, and a 404 is recorded with its status. --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 6 + apps/desktop/src/ipc/channels.ts | 4 + apps/desktop/src/ipc/methods/preview.ts | 51 ++++ apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 4 + apps/desktop/src/preview/PreviewAutomation.ts | 232 ++++++++++++++++++ .../src/components/browser/BrowserPanel.tsx | 23 ++ packages/contracts/src/ipc.ts | 51 ++++ 8 files changed, 373 insertions(+) create mode 100644 apps/desktop/src/ipc/methods/preview.ts create mode 100644 apps/desktop/src/preview/PreviewAutomation.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 0e4e2a819..1cba288f1 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -48,6 +48,7 @@ import { setTheme, showContextMenu, } from "./methods/window.ts"; +import { previewAttach, previewDetach, previewEvaluate, previewStatus } from "./methods/preview.ts"; export const installDesktopIpcHandlers = Effect.gen(function* () { const ipc = yield* DesktopIpc.DesktopIpc; @@ -72,6 +73,11 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(issueSshWebSocketToken); yield* ipc.handle(resolveSshPasswordPrompt); + yield* ipc.handle(previewAttach); + yield* ipc.handle(previewDetach); + yield* ipc.handle(previewStatus); + yield* ipc.handle(previewEvaluate); + yield* ipc.handle(getServerExposureState); yield* ipc.handle(setServerExposureMode); yield* ipc.handle(setTailscaleServeEnabled); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index acd3d4b3d..67f445ccf 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,6 +1,10 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; export const CONFIRM_CHANNEL = "desktop:confirm"; export const CAPTURE_SCREENSHOT_CHANNEL = "desktop:capture-screenshot"; +export const PREVIEW_ATTACH_CHANNEL = "desktop:preview-attach"; +export const PREVIEW_DETACH_CHANNEL = "desktop:preview-detach"; +export const PREVIEW_STATUS_CHANNEL = "desktop:preview-status"; +export const PREVIEW_EVALUATE_CHANNEL = "desktop:preview-evaluate"; 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 new file mode 100644 index 000000000..6f251e747 --- /dev/null +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -0,0 +1,51 @@ +import { + DesktopPreviewEvaluateInputSchema, + DesktopPreviewStatusSchema, + DesktopPreviewTargetSchema, +} from "@threadlines/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as PreviewAutomation from "../../preview/PreviewAutomation.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const previewAttach = makeIpcMethod({ + channel: IpcChannels.PREVIEW_ATTACH_CHANNEL, + payload: DesktopPreviewTargetSchema, + result: DesktopPreviewStatusSchema, + handler: Effect.fn("desktop.ipc.preview.attach")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.attach(input.webContentsId); + }), +}); + +export const previewDetach = makeIpcMethod({ + channel: IpcChannels.PREVIEW_DETACH_CHANNEL, + payload: DesktopPreviewTargetSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.detach")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + yield* automation.detach(input.webContentsId); + }), +}); + +export const previewStatus = makeIpcMethod({ + channel: IpcChannels.PREVIEW_STATUS_CHANNEL, + payload: DesktopPreviewTargetSchema, + result: DesktopPreviewStatusSchema, + handler: Effect.fn("desktop.ipc.preview.status")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.status(input.webContentsId); + }), +}); + +export const previewEvaluate = makeIpcMethod({ + channel: IpcChannels.PREVIEW_EVALUATE_CHANNEL, + payload: DesktopPreviewEvaluateInputSchema, + result: Schema.Unknown, + handler: Effect.fn("desktop.ipc.preview.evaluate")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.evaluate(input.webContentsId, input.expression); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index cf587767b..dfea23d77 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,6 +43,7 @@ import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopScreenCapture from "./screenCapture/DesktopScreenCapture.ts"; +import * as PreviewAutomation from "./preview/PreviewAutomation.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; @@ -173,6 +174,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopApplicationMenu.layer, DesktopStatusIndicator.layer, DesktopScreenCapture.layer, + PreviewAutomation.layer, DesktopShellEnvironment.layer, DesktopRelay.layer, desktopSshLayer, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7c54e8d0a..e18f5056c 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -109,6 +109,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), captureScreenshot: (input) => ipcRenderer.invoke(IpcChannels.CAPTURE_SCREENSHOT_CHANNEL, input), + previewAttach: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_ATTACH_CHANNEL, input), + previewDetach: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_DETACH_CHANNEL, input), + previewStatus: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_STATUS_CHANNEL, input), + previewEvaluate: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_EVALUATE_CHANNEL, input), 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 new file mode 100644 index 000000000..6a8520778 --- /dev/null +++ b/apps/desktop/src/preview/PreviewAutomation.ts @@ -0,0 +1,232 @@ +/** + * CDP control of preview tabs. + * + * Attaches Chrome DevTools Protocol to a preview 's WebContents. CDP + * rather than `executeJavaScript` because it operates at the browser level + * instead of inside the page's JavaScript sandbox: it is unaffected by + * same-origin policy, and its input events are real ones, so hover, focus and + * blur behave as they would for a person. Synthetic DOM events would not. + * + * Console and failed requests are collected here rather than on demand, + * because by the time anyone asks, the interesting message has already been + * printed. The buffers reset on navigation so they describe the current page. + */ + +import type { + DesktopPreviewConsoleEntry, + DesktopPreviewNetworkFailure, + DesktopPreviewStatus, +} from "@threadlines/contracts"; +import { webContents, type WebContents } from "electron"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +/** Enough to explain a failure without letting a chatty page grow unboundedly. */ +const MAX_CONSOLE_ENTRIES = 200; +const MAX_NETWORK_FAILURES = 100; + +export class PreviewTargetMissingError extends Schema.TaggedErrorClass()( + "PreviewTargetMissingError", + { webContentsId: Schema.Number }, +) { + override get message(): string { + return `No live preview tab with webContents id ${this.webContentsId}.`; + } +} + +export class PreviewCommandError extends Schema.TaggedErrorClass()( + "PreviewCommandError", + { webContentsId: Schema.Number, method: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Preview command ${this.method} failed for webContents ${this.webContentsId}.`; + } +} + +export type PreviewAutomationError = PreviewTargetMissingError | PreviewCommandError; + +interface AttachedTab { + contents: WebContents; + console: DesktopPreviewConsoleEntry[]; + networkFailures: DesktopPreviewNetworkFailure[]; + dispose: () => void; +} + +export class PreviewAutomation extends Context.Service< + PreviewAutomation, + { + readonly attach: ( + webContentsId: number, + ) => Effect.Effect; + readonly detach: (webContentsId: number) => Effect.Effect; + readonly status: ( + webContentsId: number, + ) => Effect.Effect; + readonly evaluate: ( + webContentsId: number, + expression: string, + ) => Effect.Effect; + } +>()("@threadlines/desktop/preview/PreviewAutomation") {} + +export const make = Effect.gen(function* PreviewAutomationMake() { + const attached = new Map(); + + const resolve = (webContentsId: number) => + Effect.suspend(() => { + const contents = webContents.fromId(webContentsId); + return contents === undefined || contents.isDestroyed() + ? Effect.fail(new PreviewTargetMissingError({ webContentsId })) + : Effect.succeed(contents); + }); + + const sendCommand = (contents: WebContents, method: string, params?: Record) => + Effect.tryPromise({ + try: () => contents.debugger.sendCommand(method, params ?? {}), + catch: (cause) => new PreviewCommandError({ webContentsId: contents.id, method, cause }), + }); + + const buildStatus = (webContentsId: number, contents: WebContents): DesktopPreviewStatus => { + const tab = attached.get(webContentsId); + return { + webContentsId, + url: contents.getURL(), + title: contents.getTitle(), + loading: contents.isLoading(), + attached: contents.debugger.isAttached(), + console: tab?.console ?? [], + networkFailures: tab?.networkFailures ?? [], + }; + }; + + const attach = Effect.fn("PreviewAutomation.attach")(function* (webContentsId: number) { + const contents = yield* resolve(webContentsId); + if (attached.has(webContentsId)) { + return buildStatus(webContentsId, contents); + } + + const tab: AttachedTab = { + contents, + console: [], + networkFailures: [], + dispose: () => {}, + }; + + if (!contents.debugger.isAttached()) { + yield* Effect.try({ + try: () => contents.debugger.attach("1.3"), + catch: (cause) => + new PreviewCommandError({ webContentsId, method: "debugger.attach", cause }), + }); + } + + const onMessage = (_event: unknown, method: string, params: Record) => { + if (method === "Runtime.consoleAPICalled") { + const args = (params.args as ReadonlyArray<{ value?: unknown }> | undefined) ?? []; + tab.console.push({ + level: String(params.type ?? "log"), + text: args + .map((arg) => (arg.value === undefined ? "" : String(arg.value))) + .join(" ") + .trim(), + at: new Date().toISOString(), + }); + if (tab.console.length > MAX_CONSOLE_ENTRIES) tab.console.shift(); + return; + } + if (method === "Runtime.exceptionThrown") { + const details = params.exceptionDetails as { text?: string } | undefined; + tab.console.push({ + level: "error", + text: details?.text ?? "Uncaught exception", + at: new Date().toISOString(), + }); + if (tab.console.length > MAX_CONSOLE_ENTRIES) tab.console.shift(); + return; + } + if (method === "Network.loadingFailed") { + tab.networkFailures.push({ + url: String(params.requestId ?? ""), + status: null, + errorText: String(params.errorText ?? "request failed"), + at: new Date().toISOString(), + }); + if (tab.networkFailures.length > MAX_NETWORK_FAILURES) tab.networkFailures.shift(); + return; + } + if (method === "Network.responseReceived") { + const response = params.response as { url?: string; status?: number } | undefined; + if (response?.status !== undefined && response.status >= 400) { + tab.networkFailures.push({ + url: response.url ?? "", + status: response.status, + errorText: null, + at: new Date().toISOString(), + }); + if (tab.networkFailures.length > MAX_NETWORK_FAILURES) tab.networkFailures.shift(); + } + return; + } + if (method === "Page.frameNavigated") { + const frame = params.frame as { parentId?: string } | undefined; + // Only the main frame: an iframe navigating is not a new page, and + // clearing on it would discard the diagnostics being asked about. + if (frame?.parentId === undefined) { + tab.console.length = 0; + tab.networkFailures.length = 0; + } + } + }; + + contents.debugger.on("message", onMessage); + const onDestroyed = () => { + attached.delete(webContentsId); + }; + contents.once("destroyed", onDestroyed); + tab.dispose = () => { + contents.debugger.off("message", onMessage); + contents.off("destroyed", onDestroyed); + }; + attached.set(webContentsId, tab); + + for (const method of ["Runtime.enable", "Page.enable", "Network.enable"]) { + yield* sendCommand(contents, method); + } + + return buildStatus(webContentsId, contents); + }); + + return PreviewAutomation.of({ + attach, + detach: (webContentsId: number) => + Effect.sync(() => { + const tab = attached.get(webContentsId); + if (tab === undefined) return; + tab.dispose(); + attached.delete(webContentsId); + if (!tab.contents.isDestroyed() && tab.contents.debugger.isAttached()) { + tab.contents.debugger.detach(); + } + }), + status: Effect.fn("PreviewAutomation.status")(function* (webContentsId: number) { + const contents = yield* resolve(webContentsId); + return buildStatus(webContentsId, contents); + }), + evaluate: Effect.fn("PreviewAutomation.evaluate")(function* ( + webContentsId: number, + expression: string, + ) { + const contents = yield* resolve(webContentsId); + const result = (yield* sendCommand(contents, "Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + })) as { result?: { value?: unknown } }; + return result.result?.value ?? null; + }), + }); +}).pipe(Effect.withSpan("PreviewAutomation.make")); + +export const layer = Layer.effect(PreviewAutomation, make); diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx index 9b5ea0301..4c2eeca58 100644 --- a/apps/web/src/components/browser/BrowserPanel.tsx +++ b/apps/web/src/components/browser/BrowserPanel.tsx @@ -34,6 +34,7 @@ declare global { } interface PreviewWebview extends HTMLElement { + getWebContentsId: () => number; getURL: () => string; canGoBack: () => boolean; canGoForward: () => boolean; @@ -70,6 +71,28 @@ export function BrowserPanel({ setAddressDraft(browserState.url ?? ""); }, [browserState.url]); + // Attach CDP once the guest exists. Done here rather than on first tool call + // so console output and failed requests are being collected from the first + // page load: by the time anyone asks what went wrong, it has already printed. + useEffect(() => { + const webview = webviewRef.current; + if (webview === null || !isElectron) { + return; + } + let attachedId: number | null = null; + const onAttached = () => { + attachedId = webview.getWebContentsId(); + void window.desktopBridge?.previewAttach?.({ webContentsId: attachedId }); + }; + webview.addEventListener("did-attach", onAttached); + return () => { + webview.removeEventListener("did-attach", onAttached); + if (attachedId !== null) { + void window.desktopBridge?.previewDetach?.({ webContentsId: attachedId }); + } + }; + }, []); + useEffect(() => { const webview = webviewRef.current; if (webview === null) { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 2ed68e6aa..fa1c7f31f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -354,6 +354,53 @@ export interface DesktopMenuActionPayload { threadId?: string; } +/** + * Preview automation, the surface the agent will eventually drive. + * + * Keyed by the guest's webContents id rather than a thread: the renderer owns + * the and is the only side that knows which element belongs to which + * thread, so it registers the pairing and the main process stays a dumb + * executor that cannot act on a tab nobody claimed. + */ +export const DesktopPreviewTargetSchema = Schema.Struct({ + webContentsId: Schema.Number, +}); +export type DesktopPreviewTarget = typeof DesktopPreviewTargetSchema.Type; + +export const DesktopPreviewEvaluateInputSchema = Schema.Struct({ + webContentsId: Schema.Number, + expression: Schema.String, +}); +export type DesktopPreviewEvaluateInput = typeof DesktopPreviewEvaluateInputSchema.Type; + +export const DesktopPreviewConsoleEntrySchema = Schema.Struct({ + level: Schema.String, + text: Schema.String, + at: Schema.String, +}); +export type DesktopPreviewConsoleEntry = typeof DesktopPreviewConsoleEntrySchema.Type; + +export const DesktopPreviewNetworkFailureSchema = Schema.Struct({ + url: Schema.String, + status: Schema.NullOr(Schema.Number), + errorText: Schema.NullOr(Schema.String), + at: Schema.String, +}); +export type DesktopPreviewNetworkFailure = typeof DesktopPreviewNetworkFailureSchema.Type; + +export const DesktopPreviewStatusSchema = Schema.Struct({ + webContentsId: Schema.Number, + url: Schema.String, + title: Schema.String, + loading: Schema.Boolean, + attached: Schema.Boolean, + /** Console output since the page last navigated, oldest first. */ + console: Schema.Array(DesktopPreviewConsoleEntrySchema), + /** Requests that failed or returned >= 400 since the page last navigated. */ + networkFailures: Schema.Array(DesktopPreviewNetworkFailureSchema), +}); +export type DesktopPreviewStatus = typeof DesktopPreviewStatusSchema.Type; + export const DesktopCaptureScreenshotInputSchema = Schema.Struct({ mode: DesktopCaptureScreenshotModeSchema, }); @@ -607,6 +654,10 @@ export interface DesktopBridge { captureScreenshot?: ( input: DesktopCaptureScreenshotInput, ) => Promise; + previewAttach?: (input: DesktopPreviewTarget) => Promise; + previewDetach?: (input: DesktopPreviewTarget) => Promise; + previewStatus?: (input: DesktopPreviewTarget) => Promise; + previewEvaluate?: (input: DesktopPreviewEvaluateInput) => Promise; setTheme: (theme: DesktopTheme) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], From 31d54365f3b8c654854d364ee8d88f72755675e8 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:33:10 -0400 Subject: [PATCH 23/82] Keep Electron's own warnings out of the page's diagnostics Electron writes security warnings straight into an unpackaged renderer's console, so they landed in the buffer whose whole purpose is telling an agent what the page under development is doing wrong. They are about how Electron hosts the page, not about the page. Filtered rather than silenced with ELECTRON_DISABLE_SECURITY_WARNINGS: that variable is process-wide and would also disable the warnings for Threadlines' own renderer, giving up a real safety net on our own code to tidy someone else's console. Matching the brand string is stable across versions, and if it ever changes a warning reappears in the buffer -- today's behaviour -- rather than anything breaking. Only warnings are dropped, never errors, so a page debugging its own Electron integration still sees everything it logged. --- apps/desktop/src/preview/PreviewAutomation.ts | 16 +++++++--- .../src/preview/previewConsoleNoise.test.ts | 30 +++++++++++++++++++ .../src/preview/previewConsoleNoise.ts | 27 +++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/preview/previewConsoleNoise.test.ts create mode 100644 apps/desktop/src/preview/previewConsoleNoise.ts diff --git a/apps/desktop/src/preview/PreviewAutomation.ts b/apps/desktop/src/preview/PreviewAutomation.ts index 6a8520778..e7c23107f 100644 --- a/apps/desktop/src/preview/PreviewAutomation.ts +++ b/apps/desktop/src/preview/PreviewAutomation.ts @@ -18,6 +18,8 @@ import type { DesktopPreviewStatus, } from "@threadlines/contracts"; import { webContents, type WebContents } from "electron"; + +import { isHostInjectedConsoleEntry } from "./previewConsoleNoise.ts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -122,10 +124,18 @@ export const make = Effect.gen(function* PreviewAutomationMake() { }); } + const pushConsole = (entry: DesktopPreviewConsoleEntry) => { + if (isHostInjectedConsoleEntry(entry)) { + return; + } + tab.console.push(entry); + if (tab.console.length > MAX_CONSOLE_ENTRIES) tab.console.shift(); + }; + const onMessage = (_event: unknown, method: string, params: Record) => { if (method === "Runtime.consoleAPICalled") { const args = (params.args as ReadonlyArray<{ value?: unknown }> | undefined) ?? []; - tab.console.push({ + pushConsole({ level: String(params.type ?? "log"), text: args .map((arg) => (arg.value === undefined ? "" : String(arg.value))) @@ -133,17 +143,15 @@ export const make = Effect.gen(function* PreviewAutomationMake() { .trim(), at: new Date().toISOString(), }); - if (tab.console.length > MAX_CONSOLE_ENTRIES) tab.console.shift(); return; } if (method === "Runtime.exceptionThrown") { const details = params.exceptionDetails as { text?: string } | undefined; - tab.console.push({ + pushConsole({ level: "error", text: details?.text ?? "Uncaught exception", at: new Date().toISOString(), }); - if (tab.console.length > MAX_CONSOLE_ENTRIES) tab.console.shift(); return; } if (method === "Network.loadingFailed") { diff --git a/apps/desktop/src/preview/previewConsoleNoise.test.ts b/apps/desktop/src/preview/previewConsoleNoise.test.ts new file mode 100644 index 000000000..deaf0c5f1 --- /dev/null +++ b/apps/desktop/src/preview/previewConsoleNoise.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isHostInjectedConsoleEntry } from "./previewConsoleNoise.ts"; + +describe("isHostInjectedConsoleEntry", () => { + it("drops Electron's injected security warning", () => { + expect( + isHostInjectedConsoleEntry({ + level: "warning", + text: "%cElectron Security Warning (Insecure Content-Security-Policy) font-weight: bold; This renderer process has either no Content Security Policy set...", + }), + ).toBe(true); + }); + + it("keeps warnings the page itself produced", () => { + expect(isHostInjectedConsoleEntry({ level: "warning", text: "deprecated prop `size`" })).toBe( + false, + ); + }); + + it("never drops errors, even if the page mentions Electron", () => { + // A page debugging its own Electron integration must still see its errors. + expect( + isHostInjectedConsoleEntry({ + level: "error", + text: "Electron Security Warning handling failed", + }), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/preview/previewConsoleNoise.ts b/apps/desktop/src/preview/previewConsoleNoise.ts new file mode 100644 index 000000000..b78e8efae --- /dev/null +++ b/apps/desktop/src/preview/previewConsoleNoise.ts @@ -0,0 +1,27 @@ +/** + * Console output the host injected into the guest, rather than the page's own. + * + * Electron writes security warnings straight into an unpackaged renderer's + * console. They are about how Electron is hosting the page, not about the page, + * so they are noise in a buffer whose entire purpose is to tell an agent what + * the page under development is doing wrong. + * + * Filtered rather than suppressed with ELECTRON_DISABLE_SECURITY_WARNINGS, + * because that variable is process-wide: it would also silence the warnings for + * Threadlines' own renderer, losing a real safety net on our own code to clean + * up someone else's console. + * + * Matching on the warning's brand string is deliberate. It is stable across + * Electron versions, and if it ever changes the failure mode is that a warning + * reappears in the buffer -- the behaviour we have today -- rather than + * anything breaking. + */ + +const ELECTRON_WARNING_MARKER = "Electron Security Warning"; + +export function isHostInjectedConsoleEntry(entry: { + readonly level: string; + readonly text: string; +}): boolean { + return entry.level === "warning" && entry.text.includes(ELECTRON_WARNING_MARKER); +} From 276ed238a955c168d0f549ef6863ffb8dc1ed651 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:43:53 -0400 Subject: [PATCH 24/82] Report the address of a request that failed below HTTP Network.loadingFailed carries only a request id, and it was being stored in the url field. A 404 hid the mistake, because that path arrives via Network.responseReceived which does carry an address; only failures below HTTP -- DNS, connection refused, a blocked port -- take the loadingFailed path, and those were reported as an opaque id. Request addresses are now remembered from Network.requestWillBeSent and resolved on failure, then forgotten, so the map cannot grow with the page. Also capture the Log domain. Runtime.consoleAPICalled only sees what the page itself printed, which misses the browser's own complaints: CSP violations, CORS refusals, blocked ports, mixed content. Those are precisely the failures a developer asks an agent about, and the page never logs them. Verified against a page whose requests fail both ways: the blocked-port request now reports http://127.0.0.1:9/x rather than a request id. --- apps/desktop/src/preview/PreviewAutomation.ts | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/preview/PreviewAutomation.ts b/apps/desktop/src/preview/PreviewAutomation.ts index e7c23107f..90ca8de46 100644 --- a/apps/desktop/src/preview/PreviewAutomation.ts +++ b/apps/desktop/src/preview/PreviewAutomation.ts @@ -53,6 +53,12 @@ interface AttachedTab { contents: WebContents; console: DesktopPreviewConsoleEntry[]; networkFailures: DesktopPreviewNetworkFailure[]; + /** + * requestId -> url, because `Network.loadingFailed` reports only the id. + * Without this a request that fails below HTTP -- DNS, connection refused, + * blocked -- would be reported as an opaque id instead of an address. + */ + requestUrls: Map; dispose: () => void; } @@ -113,6 +119,7 @@ export const make = Effect.gen(function* PreviewAutomationMake() { contents, console: [], networkFailures: [], + requestUrls: new Map(), dispose: () => {}, }; @@ -154,16 +161,38 @@ export const make = Effect.gen(function* PreviewAutomationMake() { }); return; } + if (method === "Network.requestWillBeSent") { + const requestId = String(params.requestId ?? ""); + const request = params.request as { url?: string } | undefined; + if (requestId !== "" && request?.url !== undefined) { + tab.requestUrls.set(requestId, request.url); + } + return; + } if (method === "Network.loadingFailed") { + const requestId = String(params.requestId ?? ""); tab.networkFailures.push({ - url: String(params.requestId ?? ""), + url: tab.requestUrls.get(requestId) ?? "", status: null, errorText: String(params.errorText ?? "request failed"), at: new Date().toISOString(), }); + tab.requestUrls.delete(requestId); if (tab.networkFailures.length > MAX_NETWORK_FAILURES) tab.networkFailures.shift(); return; } + if (method === "Log.entryAdded") { + // Browser-level messages the page never printed itself: CSP violations, + // CORS refusals, mixed content. Exactly the failures a developer asks + // an agent about, and invisible to Runtime.consoleAPICalled. + const entry = params.entry as { level?: string; text?: string } | undefined; + pushConsole({ + level: entry?.level ?? "info", + text: entry?.text ?? "", + at: new Date().toISOString(), + }); + return; + } if (method === "Network.responseReceived") { const response = params.response as { url?: string; status?: number } | undefined; if (response?.status !== undefined && response.status >= 400) { @@ -184,6 +213,7 @@ export const make = Effect.gen(function* PreviewAutomationMake() { if (frame?.parentId === undefined) { tab.console.length = 0; tab.networkFailures.length = 0; + tab.requestUrls.clear(); } } }; @@ -199,7 +229,7 @@ export const make = Effect.gen(function* PreviewAutomationMake() { }; attached.set(webContentsId, tab); - for (const method of ["Runtime.enable", "Page.enable", "Network.enable"]) { + for (const method of ["Runtime.enable", "Page.enable", "Network.enable", "Log.enable"]) { yield* sendCommand(contents, method); } From fec0742d74c761940fc9b50d02c955b8fab4343c Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:50:25 -0400 Subject: [PATCH 25/82] Let the agent see a page and act on what it saw A snapshot from the accessibility tree, and clicks and typing addressed by the refs that snapshot hands back. Targeting by ref rather than by a selector string means there is nothing to parse and nothing ambiguous: the agent can only act on something it was actually shown. The tree is filtered to roles worth acting on, plus a few landmarks for orientation. A real page's full tree runs to thousands of generic containers, which would bury the handful of things that can be pressed. Nameless controls are dropped because an element that cannot be described cannot be chosen deliberately. Input goes through CDP rather than element.click(): hover, focus and blur fire as they would for a person, and handlers that check isTrusted behave the same way. Clearing a field uses the editing command directly. Dispatching Meta+A as a raw key does not work -- the browser resolves shortcuts to editing commands above the layer CDP injects at, so the chord selects nothing and the insert lands wherever the caret was. A click centres the caret, so the text arrived mid-word: old@examp + new@example.com + le.com. --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 13 +- apps/desktop/src/ipc/channels.ts | 3 + apps/desktop/src/ipc/methods/preview.ts | 33 ++++ apps/desktop/src/preload.ts | 3 + apps/desktop/src/preview/PreviewAutomation.ts | 184 +++++++++++++++++- packages/contracts/src/ipc.ts | 41 ++++ 6 files changed, 275 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 1cba288f1..7eeb93cf2 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -48,7 +48,15 @@ import { setTheme, showContextMenu, } from "./methods/window.ts"; -import { previewAttach, previewDetach, previewEvaluate, previewStatus } from "./methods/preview.ts"; +import { + previewAttach, + previewClick, + previewDetach, + previewEvaluate, + previewSnapshot, + previewStatus, + previewType, +} from "./methods/preview.ts"; export const installDesktopIpcHandlers = Effect.gen(function* () { const ipc = yield* DesktopIpc.DesktopIpc; @@ -77,6 +85,9 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(previewDetach); yield* ipc.handle(previewStatus); yield* ipc.handle(previewEvaluate); + yield* ipc.handle(previewSnapshot); + yield* ipc.handle(previewClick); + yield* ipc.handle(previewType); 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 67f445ccf..77140130d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -5,6 +5,9 @@ export const PREVIEW_ATTACH_CHANNEL = "desktop:preview-attach"; export const PREVIEW_DETACH_CHANNEL = "desktop:preview-detach"; export const PREVIEW_STATUS_CHANNEL = "desktop:preview-status"; export const PREVIEW_EVALUATE_CHANNEL = "desktop:preview-evaluate"; +export const PREVIEW_SNAPSHOT_CHANNEL = "desktop:preview-snapshot"; +export const PREVIEW_CLICK_CHANNEL = "desktop:preview-click"; +export const PREVIEW_TYPE_CHANNEL = "desktop:preview-type"; 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 6f251e747..aaae08481 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,5 +1,8 @@ import { + DesktopPreviewClickInputSchema, DesktopPreviewEvaluateInputSchema, + DesktopPreviewSnapshotSchema, + DesktopPreviewTypeInputSchema, DesktopPreviewStatusSchema, DesktopPreviewTargetSchema, } from "@threadlines/contracts"; @@ -49,3 +52,33 @@ export const previewEvaluate = makeIpcMethod({ return yield* automation.evaluate(input.webContentsId, input.expression); }), }); + +export const previewSnapshot = makeIpcMethod({ + channel: IpcChannels.PREVIEW_SNAPSHOT_CHANNEL, + payload: DesktopPreviewTargetSchema, + result: DesktopPreviewSnapshotSchema, + handler: Effect.fn("desktop.ipc.preview.snapshot")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.snapshot(input.webContentsId); + }), +}); + +export const previewClick = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLICK_CHANNEL, + payload: DesktopPreviewClickInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.click")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + yield* automation.click(input.webContentsId, input.ref); + }), +}); + +export const previewType = makeIpcMethod({ + channel: IpcChannels.PREVIEW_TYPE_CHANNEL, + payload: DesktopPreviewTypeInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.type")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + yield* automation.type(input); + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e18f5056c..165902177 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -113,6 +113,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { previewDetach: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_DETACH_CHANNEL, input), previewStatus: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_STATUS_CHANNEL, input), previewEvaluate: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_EVALUATE_CHANNEL, input), + previewSnapshot: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_SNAPSHOT_CHANNEL, input), + previewClick: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLICK_CHANNEL, input), + previewType: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_TYPE_CHANNEL, input), 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 90ca8de46..8be265884 100644 --- a/apps/desktop/src/preview/PreviewAutomation.ts +++ b/apps/desktop/src/preview/PreviewAutomation.ts @@ -14,7 +14,9 @@ import type { DesktopPreviewConsoleEntry, + DesktopPreviewElement, DesktopPreviewNetworkFailure, + DesktopPreviewSnapshot, DesktopPreviewStatus, } from "@threadlines/contracts"; import { webContents, type WebContents } from "electron"; @@ -25,6 +27,43 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +/** + * Roles worth handing to an agent. The full accessibility tree of a real page + * runs to thousands of nodes, most of them generic containers and text, which + * would bury the handful of things that can actually be acted on. + */ +const ACTIONABLE_ROLES: ReadonlySet = new Set([ + "button", + "link", + "textbox", + "searchbox", + "checkbox", + "radio", + "combobox", + "listbox", + "menuitem", + "menuitemcheckbox", + "menuitemradio", + "option", + "slider", + "spinbutton", + "switch", + "tab", + "textarea", +]); + +/** Kept for orientation: they tell the agent where it is, not what to press. */ +const LANDMARK_ROLES: ReadonlySet = new Set([ + "heading", + "alert", + "status", + "dialog", + "navigation", + "main", +]); + +const MAX_SNAPSHOT_ELEMENTS = 200; + /** Enough to explain a failure without letting a chatty page grow unboundedly. */ const MAX_CONSOLE_ENTRIES = 200; const MAX_NETWORK_FAILURES = 100; @@ -76,6 +115,19 @@ export class PreviewAutomation extends Context.Service< webContentsId: number, expression: string, ) => Effect.Effect; + readonly snapshot: ( + webContentsId: number, + ) => Effect.Effect; + readonly click: ( + webContentsId: number, + ref: number, + ) => Effect.Effect; + readonly type: (input: { + webContentsId: number; + ref: number; + text: string; + clear?: boolean | undefined; + }) => Effect.Effect; } >()("@threadlines/desktop/preview/PreviewAutomation") {} @@ -229,13 +281,43 @@ export const make = Effect.gen(function* PreviewAutomationMake() { }; attached.set(webContentsId, tab); - for (const method of ["Runtime.enable", "Page.enable", "Network.enable", "Log.enable"]) { + for (const method of [ + "Runtime.enable", + "Page.enable", + "Network.enable", + "Log.enable", + "Accessibility.enable", + ]) { yield* sendCommand(contents, method); } return buildStatus(webContentsId, contents); }); + const centerOf = Effect.fn("PreviewAutomation.centerOf")(function* ( + contents: WebContents, + backendNodeId: number, + ) { + const box = (yield* sendCommand(contents, "DOM.getBoxModel", { backendNodeId })) as { + model?: { content?: ReadonlyArray }; + }; + const quad = box.model?.content; + if (quad === undefined || quad.length < 8) { + return yield* Effect.fail( + new PreviewCommandError({ + webContentsId: contents.id, + method: "DOM.getBoxModel", + cause: `element ${backendNodeId} has no layout box`, + }), + ); + } + // Centre of the border box: the quad is four corner pairs, clockwise. + return { + x: (quad[0]! + quad[2]! + quad[4]! + quad[6]!) / 4, + y: (quad[1]! + quad[3]! + quad[5]! + quad[7]!) / 4, + }; + }); + return PreviewAutomation.of({ attach, detach: (webContentsId: number) => @@ -252,6 +334,106 @@ export const make = Effect.gen(function* PreviewAutomationMake() { const contents = yield* resolve(webContentsId); return buildStatus(webContentsId, contents); }), + snapshot: Effect.fn("PreviewAutomation.snapshot")(function* (webContentsId: number) { + const contents = yield* resolve(webContentsId); + const tree = (yield* sendCommand(contents, "Accessibility.getFullAXTree", {})) as { + nodes?: ReadonlyArray>; + }; + const elements: DesktopPreviewElement[] = []; + for (const node of tree.nodes ?? []) { + if (elements.length >= MAX_SNAPSHOT_ELEMENTS) break; + if (node.ignored === true) continue; + const role = String((node.role as { value?: unknown } | undefined)?.value ?? ""); + const isActionable = ACTIONABLE_ROLES.has(role); + if (!isActionable && !LANDMARK_ROLES.has(role)) continue; + const name = String((node.name as { value?: unknown } | undefined)?.value ?? "").trim(); + // An actionable control with no accessible name cannot be described to + // an agent, and is usually decorative; a landmark without one is noise. + if (name === "") continue; + const backendNodeId = node.backendDOMNodeId; + if (typeof backendNodeId !== "number") continue; + const properties = + (node.properties as ReadonlyArray<{ name?: string; value?: { value?: unknown } }>) ?? []; + const disabled = properties.some( + (property) => property.name === "disabled" && property.value?.value === true, + ); + const rawValue = (node.value as { value?: unknown } | undefined)?.value; + elements.push({ + ref: backendNodeId, + role, + name, + value: rawValue === undefined || rawValue === null ? null : String(rawValue), + disabled, + }); + } + return { ...buildStatus(webContentsId, contents), elements }; + }), + click: Effect.fn("PreviewAutomation.click")(function* (webContentsId: number, ref: number) { + const contents = yield* resolve(webContentsId); + const point = yield* centerOf(contents, ref); + // Real input events rather than element.click(): hover, focus and blur + // fire as they would for a person, and a handler that checks isTrusted + // behaves the same way too. + yield* sendCommand(contents, "Input.dispatchMouseEvent", { + type: "mousePressed", + x: point.x, + y: point.y, + button: "left", + clickCount: 1, + }); + yield* sendCommand(contents, "Input.dispatchMouseEvent", { + type: "mouseReleased", + x: point.x, + y: point.y, + button: "left", + clickCount: 1, + }); + }), + type: Effect.fn("PreviewAutomation.type")(function* (input: { + webContentsId: number; + ref: number; + text: string; + clear?: boolean | undefined; + }) { + const contents = yield* resolve(input.webContentsId); + const point = yield* centerOf(contents, input.ref); + yield* sendCommand(contents, "Input.dispatchMouseEvent", { + type: "mousePressed", + x: point.x, + y: point.y, + button: "left", + clickCount: 1, + }); + yield* sendCommand(contents, "Input.dispatchMouseEvent", { + type: "mouseReleased", + x: point.x, + y: point.y, + button: "left", + clickCount: 1, + }); + if (input.clear === true) { + // `commands` invokes the editing command directly, which is what a + // modifier chord ultimately triggers. Dispatching Meta+A as a raw key + // does not: the browser resolves shortcuts to editing commands above + // the layer CDP injects at, so the keypress arrives and selects + // nothing, and the insert below lands wherever the caret happened to + // be -- mid-word, since a click centres it. + yield* sendCommand(contents, "Input.dispatchKeyEvent", { + type: "keyDown", + key: "a", + code: "KeyA", + windowsVirtualKeyCode: 65, + commands: ["selectAll"], + }); + yield* sendCommand(contents, "Input.dispatchKeyEvent", { + type: "keyUp", + key: "a", + code: "KeyA", + windowsVirtualKeyCode: 65, + }); + } + yield* sendCommand(contents, "Input.insertText", { text: input.text }); + }), evaluate: Effect.fn("PreviewAutomation.evaluate")(function* ( webContentsId: number, expression: string, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index fa1c7f31f..6641e1cd5 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -388,6 +388,38 @@ export const DesktopPreviewNetworkFailureSchema = Schema.Struct({ }); export type DesktopPreviewNetworkFailure = typeof DesktopPreviewNetworkFailureSchema.Type; +/** + * An element the agent can address, taken from the accessibility tree. + * + * `ref` is a backend node id: stable for the life of the document, and handed + * back verbatim to act on the element. Targeting by ref rather than by a + * selector string means there is nothing to parse and nothing ambiguous -- the + * agent acts on something it was actually shown. + */ +export const DesktopPreviewElementSchema = Schema.Struct({ + ref: Schema.Number, + role: Schema.String, + name: Schema.String, + value: Schema.NullOr(Schema.String), + disabled: Schema.Boolean, +}); +export type DesktopPreviewElement = typeof DesktopPreviewElementSchema.Type; + +export const DesktopPreviewClickInputSchema = Schema.Struct({ + webContentsId: Schema.Number, + ref: Schema.Number, +}); +export type DesktopPreviewClickInput = typeof DesktopPreviewClickInputSchema.Type; + +export const DesktopPreviewTypeInputSchema = Schema.Struct({ + webContentsId: Schema.Number, + ref: Schema.Number, + text: Schema.String, + /** Replace what is already in the field rather than appending to it. */ + clear: Schema.optional(Schema.Boolean), +}); +export type DesktopPreviewTypeInput = typeof DesktopPreviewTypeInputSchema.Type; + export const DesktopPreviewStatusSchema = Schema.Struct({ webContentsId: Schema.Number, url: Schema.String, @@ -401,6 +433,12 @@ export const DesktopPreviewStatusSchema = Schema.Struct({ }); export type DesktopPreviewStatus = typeof DesktopPreviewStatusSchema.Type; +export const DesktopPreviewSnapshotSchema = Schema.Struct({ + ...DesktopPreviewStatusSchema.fields, + elements: Schema.Array(DesktopPreviewElementSchema), +}); +export type DesktopPreviewSnapshot = typeof DesktopPreviewSnapshotSchema.Type; + export const DesktopCaptureScreenshotInputSchema = Schema.Struct({ mode: DesktopCaptureScreenshotModeSchema, }); @@ -658,6 +696,9 @@ export interface DesktopBridge { previewDetach?: (input: DesktopPreviewTarget) => Promise; previewStatus?: (input: DesktopPreviewTarget) => Promise; previewEvaluate?: (input: DesktopPreviewEvaluateInput) => Promise; + previewSnapshot?: (input: DesktopPreviewTarget) => Promise; + previewClick?: (input: DesktopPreviewClickInput) => Promise; + previewType?: (input: DesktopPreviewTypeInput) => Promise; setTheme: (theme: DesktopTheme) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], From b9205da3da6151889a47977e0e4c85facd9d63f1 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:01:42 -0400 Subject: [PATCH 26/82] Offer what is listening instead of an empty address bar A blank preview asks for the one thing nobody remembers: the port a dev server happened to pick. It now lists what is actually listening, so opening the thing you are working on is a click rather than a guess. Discovery parses lsof's -F field output. The human-readable table changes shape between versions and locales; the tagged format does not. Tags arrive in sets, so every address belongs to the process last named, and a process with no command clears the previous name rather than inheriting it -- attributing a port to the wrong program is worse than showing none. Ports listed once per address family collapse to one row, established connections are skipped since they are not listeners, and addresses this machine's browser cannot reach are dropped. Local by design: the preview is a browser on this machine, so a remote environment's servers would not be reachable from it anyway. Also adds screenshot capture, taken through CDP rather than capturePage so the image is the page itself even when the window is occluded. --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 4 + apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/preview.ts | 23 ++++ apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 2 + apps/desktop/src/preview/LocalServers.ts | 54 +++++++++ apps/desktop/src/preview/PreviewAutomation.ts | 21 ++++ .../src/preview/parseListeningPorts.test.ts | 42 +++++++ .../src/preview/parseListeningPorts.ts | 71 +++++++++++ .../src/components/browser/BrowserPanel.tsx | 113 +++++++++++++++++- packages/contracts/src/ipc.ts | 17 +++ 11 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/preview/LocalServers.ts create mode 100644 apps/desktop/src/preview/parseListeningPorts.test.ts create mode 100644 apps/desktop/src/preview/parseListeningPorts.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 7eeb93cf2..6582915ad 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -53,6 +53,8 @@ import { previewClick, previewDetach, previewEvaluate, + previewLocalServers, + previewScreenshot, previewSnapshot, previewStatus, previewType, @@ -88,6 +90,8 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(previewSnapshot); yield* ipc.handle(previewClick); yield* ipc.handle(previewType); + yield* ipc.handle(previewLocalServers); + yield* ipc.handle(previewScreenshot); 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 77140130d..f5a647660 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -8,6 +8,8 @@ export const PREVIEW_EVALUATE_CHANNEL = "desktop:preview-evaluate"; export const PREVIEW_SNAPSHOT_CHANNEL = "desktop:preview-snapshot"; 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 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 aaae08481..2b818735b 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,6 +1,8 @@ import { + DesktopLocalServerSchema, DesktopPreviewClickInputSchema, DesktopPreviewEvaluateInputSchema, + DesktopPreviewScreenshotSchema, DesktopPreviewSnapshotSchema, DesktopPreviewTypeInputSchema, DesktopPreviewStatusSchema, @@ -9,6 +11,7 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as LocalServers from "../../preview/LocalServers.ts"; import * as PreviewAutomation from "../../preview/PreviewAutomation.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; @@ -82,3 +85,23 @@ export const previewType = makeIpcMethod({ yield* automation.type(input); }), }); + +export const previewLocalServers = makeIpcMethod({ + channel: IpcChannels.PREVIEW_LOCAL_SERVERS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(DesktopLocalServerSchema), + handler: Effect.fn("desktop.ipc.preview.localServers")(function* () { + const servers = yield* LocalServers.LocalServers; + return yield* servers.scan(); + }), +}); + +export const previewScreenshot = makeIpcMethod({ + channel: IpcChannels.PREVIEW_SCREENSHOT_CHANNEL, + payload: DesktopPreviewTargetSchema, + result: DesktopPreviewScreenshotSchema, + handler: Effect.fn("desktop.ipc.preview.screenshot")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + return yield* automation.screenshot(input.webContentsId); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index dfea23d77..1698eadf4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,6 +43,7 @@ import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopScreenCapture from "./screenCapture/DesktopScreenCapture.ts"; +import * as LocalServers from "./preview/LocalServers.ts"; import * as PreviewAutomation from "./preview/PreviewAutomation.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; @@ -175,6 +176,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopStatusIndicator.layer, DesktopScreenCapture.layer, PreviewAutomation.layer, + LocalServers.layer, DesktopShellEnvironment.layer, DesktopRelay.layer, desktopSshLayer, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 165902177..a44a3a4fa 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -116,6 +116,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { previewSnapshot: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_SNAPSHOT_CHANNEL, input), previewClick: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLICK_CHANNEL, input), 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), 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/LocalServers.ts b/apps/desktop/src/preview/LocalServers.ts new file mode 100644 index 000000000..50cd7b649 --- /dev/null +++ b/apps/desktop/src/preview/LocalServers.ts @@ -0,0 +1,54 @@ +/** + * Servers currently listening on this machine. + * + * Discovered rather than configured: the point is to answer "what is running + * right now" without the user knowing a port number. Local by design -- the + * preview is a browser on this machine, so a remote environment's servers + * would not be reachable from it anyway. + */ + +import type { DesktopLocalServer } from "@threadlines/contracts"; +import { execFile } from "node:child_process"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { parseListeningPorts } from "./parseListeningPorts.ts"; + +const LSOF_TIMEOUT_MS = 5_000; + +export class LocalServers extends Context.Service< + LocalServers, + { readonly scan: () => Effect.Effect> } +>()("@threadlines/desktop/preview/LocalServers") {} + +export const make = Effect.gen(function* LocalServersMake() { + return LocalServers.of({ + scan: Effect.fn("LocalServers.scan")(function* () { + // Never fails the caller: an empty list renders as "nothing running", + // which is the truth whether lsof is missing or genuinely found nothing. + return yield* Effect.promise( + () => + new Promise>((resolve) => { + if (process.platform === "win32") { + resolve([]); + return; + } + execFile( + "lsof", + ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], + { timeout: LSOF_TIMEOUT_MS, maxBuffer: 4_000_000 }, + (_error, stdout) => { + // 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 ?? "")); + }, + ); + }), + ); + }), + }); +}).pipe(Effect.withSpan("LocalServers.make")); + +export const layer = Layer.effect(LocalServers, make); diff --git a/apps/desktop/src/preview/PreviewAutomation.ts b/apps/desktop/src/preview/PreviewAutomation.ts index 8be265884..9d0827b4f 100644 --- a/apps/desktop/src/preview/PreviewAutomation.ts +++ b/apps/desktop/src/preview/PreviewAutomation.ts @@ -17,6 +17,7 @@ import type { DesktopPreviewElement, DesktopPreviewNetworkFailure, DesktopPreviewSnapshot, + DesktopPreviewScreenshot, DesktopPreviewStatus, } from "@threadlines/contracts"; import { webContents, type WebContents } from "electron"; @@ -118,6 +119,9 @@ export class PreviewAutomation extends Context.Service< readonly snapshot: ( webContentsId: number, ) => Effect.Effect; + readonly screenshot: ( + webContentsId: number, + ) => Effect.Effect; readonly click: ( webContentsId: number, ref: number, @@ -368,6 +372,23 @@ export const make = Effect.gen(function* PreviewAutomationMake() { } return { ...buildStatus(webContentsId, contents), elements }; }), + 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 + // itself, unaffected by the window being occluded or offscreen. + const shot = (yield* sendCommand(contents, "Page.captureScreenshot", { + format: "png", + captureBeyondViewport: false, + })) as { data?: string }; + const metrics = (yield* sendCommand(contents, "Page.getLayoutMetrics", {})) as { + cssVisualViewport?: { clientWidth?: number; clientHeight?: number }; + }; + return { + dataUrl: `data:image/png;base64,${shot.data ?? ""}`, + width: Math.round(metrics.cssVisualViewport?.clientWidth ?? 0), + height: Math.round(metrics.cssVisualViewport?.clientHeight ?? 0), + }; + }), click: Effect.fn("PreviewAutomation.click")(function* (webContentsId: number, ref: number) { const contents = yield* resolve(webContentsId); const point = yield* centerOf(contents, ref); diff --git a/apps/desktop/src/preview/parseListeningPorts.test.ts b/apps/desktop/src/preview/parseListeningPorts.test.ts new file mode 100644 index 000000000..c1b18c014 --- /dev/null +++ b/apps/desktop/src/preview/parseListeningPorts.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseListeningPorts } from "./parseListeningPorts.ts"; + +describe("parseListeningPorts", () => { + it("pairs each address with the process it was listed under", () => { + const output = ["p123", "cnode", "n*:5173", "p456", "cPython", "n127.0.0.1:8000"].join("\n"); + + // Sorted by port, so the lowest listener is first regardless of lsof order. + expect(parseListeningPorts(output)).toEqual([ + { port: 5173, processName: "node", pid: 123 }, + { port: 8000, processName: "Python", pid: 456 }, + ]); + }); + + it("collapses a port listed once per address family", () => { + const output = ["p1", "cnode", "n*:3000", "n[::1]:3000"].join("\n"); + + expect(parseListeningPorts(output)).toEqual([{ port: 3000, processName: "node", pid: 1 }]); + }); + + it("ignores addresses this machine's browser cannot reach", () => { + const output = ["p1", "cnode", "n192.168.1.50:9999", "n*:3000"].join("\n"); + + expect(parseListeningPorts(output).map((entry) => entry.port)).toEqual([3000]); + }); + + it("ignores established connections, which are not listeners", () => { + const output = ["p1", "cnode", "n127.0.0.1:5173->127.0.0.1:62000"].join("\n"); + + expect(parseListeningPorts(output)).toEqual([]); + }); + + it("does not attribute a port to the previous process when the command is missing", () => { + const output = ["p1", "cnode", "n*:3000", "p2", "n*:4000"].join("\n"); + + expect(parseListeningPorts(output)).toEqual([ + { port: 3000, processName: "node", pid: 1 }, + { port: 4000, processName: "", pid: 2 }, + ]); + }); +}); diff --git a/apps/desktop/src/preview/parseListeningPorts.ts b/apps/desktop/src/preview/parseListeningPorts.ts new file mode 100644 index 000000000..538bb8fd1 --- /dev/null +++ b/apps/desktop/src/preview/parseListeningPorts.ts @@ -0,0 +1,71 @@ +/** + * Parses `lsof -iTCP -sTCP:LISTEN -P -n -F pcn`. + * + * `-F` emits one field per line behind a single-character tag, which is the + * only lsof output worth depending on: the human-readable table changes shape + * between versions and locales. Tags arrive in sets -- `p` (pid) and `c` + * (command) describe the process, and every `n` (address) line after them + * belongs to that process until the next `p`. + */ + +export interface ListeningPort { + port: number; + processName: string; + pid: number; +} + +/** Addresses that a browser on this machine can actually reach. */ +function isLocallyReachable(address: string): boolean { + const host = address.slice(0, address.lastIndexOf(":")); + return ( + host === "*" || + host === "" || + host === "127.0.0.1" || + host === "localhost" || + host === "[::1]" || + host === "::1" || + host === "0.0.0.0" || + host === "[::]" + ); +} + +export function parseListeningPorts(lsofOutput: string): ListeningPort[] { + const byPort = new Map(); + let pid: number | null = null; + let processName = ""; + + for (const line of lsofOutput.split("\n")) { + const tag = line[0]; + const value = line.slice(1); + if (tag === "p") { + const parsed = Number.parseInt(value, 10); + pid = Number.isNaN(parsed) ? null : parsed; + // A new process clears the previous command: pairing an address with the + // wrong name is worse than showing none. + processName = ""; + continue; + } + if (tag === "c") { + processName = value; + continue; + } + if (tag !== "n" || pid === null) { + continue; + } + // IPv6 arrives bracketed, and lsof writes "->" for established + // connections; a listener has no peer. + if (value.includes("->") || !isLocallyReachable(value)) { + continue; + } + const port = Number.parseInt(value.slice(value.lastIndexOf(":") + 1), 10); + if (Number.isNaN(port) || port <= 0) { + continue; + } + // The same port often appears twice, once per address family. One entry. + if (!byPort.has(port)) { + byPort.set(port, { port, processName, pid }); + } + } + + return [...byPort.values()].sort((a, b) => a.port - b.port); +} diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx index 4c2eeca58..5e11cf5e2 100644 --- a/apps/web/src/components/browser/BrowserPanel.tsx +++ b/apps/web/src/components/browser/BrowserPanel.tsx @@ -1,5 +1,13 @@ -import type { ScopedThreadRef } from "@threadlines/contracts"; -import { ArrowLeftIcon, ArrowRightIcon, ExternalLinkIcon, RotateCwIcon, XIcon } from "lucide-react"; +import type { DesktopLocalServer, ScopedThreadRef } from "@threadlines/contracts"; +import { + ArrowLeftIcon, + ArrowRightIcon, + CameraIcon, + ExternalLinkIcon, + RadioTowerIcon, + RotateCwIcon, + XIcon, +} from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { @@ -135,6 +143,20 @@ export function BrowserPanel({ [addressDraft, setBrowserUrl, threadRef], ); + const captureScreenshot = useCallback(() => { + const webview = webviewRef.current; + if (webview === null || !isElectron) { + return; + } + void window.desktopBridge + ?.previewScreenshot?.({ webContentsId: webview.getWebContentsId() }) + .then((shot) => { + if (shot?.dataUrl) { + void navigator.clipboard.writeText(shot.dataUrl).catch(() => {}); + } + }); + }, []); + const preset = BROWSER_VIEWPORT_PRESETS.find((entry) => entry.id === browserState.viewportPresetId) ?? BROWSER_VIEWPORT_PRESETS[0]; @@ -192,12 +214,25 @@ export function BrowserPanel({ ))} + + + +
+ {isElectron && browserState.url === null ? ( + { + const url = `http://localhost:${port}`; + setBrowserUrl(threadRef, url); + void webviewRef.current?.loadURL(url); + }} + /> + ) : null} {isElectron ? (
); } + +/** + * 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…"} +

+ ) : ( +
+ {servers.map((server) => ( + + ))} +
+ )} +

+ Select a listening port to open it here. +

+
+ ); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 6641e1cd5..2733b5c2b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -433,6 +433,21 @@ export const DesktopPreviewStatusSchema = Schema.Struct({ }); export type DesktopPreviewStatus = typeof DesktopPreviewStatusSchema.Type; +export const DesktopPreviewScreenshotSchema = Schema.Struct({ + dataUrl: Schema.String, + width: Schema.Number, + height: Schema.Number, +}); +export type DesktopPreviewScreenshot = typeof DesktopPreviewScreenshotSchema.Type; + +/** A server listening on this machine, offered as a destination for the preview. */ +export const DesktopLocalServerSchema = Schema.Struct({ + port: Schema.Number, + processName: Schema.String, + pid: Schema.Number, +}); +export type DesktopLocalServer = typeof DesktopLocalServerSchema.Type; + export const DesktopPreviewSnapshotSchema = Schema.Struct({ ...DesktopPreviewStatusSchema.fields, elements: Schema.Array(DesktopPreviewElementSchema), @@ -699,6 +714,8 @@ export interface DesktopBridge { previewSnapshot?: (input: DesktopPreviewTarget) => Promise; previewClick?: (input: DesktopPreviewClickInput) => Promise; previewType?: (input: DesktopPreviewTypeInput) => Promise; + previewLocalServers?: () => Promise; + previewScreenshot?: (input: DesktopPreviewTarget) => Promise; setTheme: (theme: DesktopTheme) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], From 484ac3e701bf91242e702e8cf0f0c384a07615f3 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:19:11 -0400 Subject: [PATCH 27/82] Give the browser panel tabs Every tab keeps its own element, mounted whether or not it is showing. Switching tabs would otherwise tear down the guest and take the CDP attachment with it, losing the page and the console and network history collected from it -- and reloading on the way back, which is the one thing a preview must not do while you are watching something happen. Inactive tabs are hidden with visibility rather than display so the guest keeps its layout: a page collapsed to nothing reports a meaningless viewport to a screenshot or to an agent measuring an element. Only the visible tab drives the toolbar, so a background tab finishing a load cannot repaint controls describing a different page. Closing the active tab selects its right-hand neighbour, falling back to the left at the end of the strip, so closing several in a row walks along instead of jumping to an end. Closing the last one leaves a blank tab rather than an empty frame. Imperative webview calls are guarded: a guest rejects them until it has attached and fired dom-ready, so a tab opened and immediately pointed somewhere threw. Navigation happens anyway through the bound src, which is why the failure was invisible except in the console. --- apps/web/src/browserPanelStore.test.ts | 33 ++ apps/web/src/browserPanelStore.ts | 179 ++++++-- .../src/components/browser/BrowserPanel.tsx | 422 ++++++++++++------ 3 files changed, 481 insertions(+), 153 deletions(-) create mode 100644 apps/web/src/browserPanelStore.test.ts diff --git a/apps/web/src/browserPanelStore.test.ts b/apps/web/src/browserPanelStore.test.ts new file mode 100644 index 000000000..b31e1962e --- /dev/null +++ b/apps/web/src/browserPanelStore.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { makeBrowserTab, nextActiveTabId, type BrowserTab } from "./browserPanelStore"; + +function tabs(count: number): BrowserTab[] { + return Array.from({ length: count }, () => makeBrowserTab()); +} + +describe("nextActiveTabId", () => { + it("keeps the active tab when a different one closes", () => { + const [a, b, c] = tabs(3) as [BrowserTab, BrowserTab, BrowserTab]; + + expect(nextActiveTabId([a, b, c], a.id, b.id)).toBe(b.id); + }); + + it("walks right when the active tab closes", () => { + const [a, b, c] = tabs(3) as [BrowserTab, BrowserTab, BrowserTab]; + + expect(nextActiveTabId([a, b, c], b.id, b.id)).toBe(c.id); + }); + + it("falls back to the left at the end of the strip", () => { + const [a, b, c] = tabs(3) as [BrowserTab, BrowserTab, BrowserTab]; + + expect(nextActiveTabId([a, b, c], c.id, c.id)).toBe(b.id); + }); + + it("reports nothing left when the last tab closes", () => { + const [only] = tabs(1) as [BrowserTab]; + + expect(nextActiveTabId([only], only.id, only.id)).toBeNull(); + }); +}); diff --git a/apps/web/src/browserPanelStore.ts b/apps/web/src/browserPanelStore.ts index 39dd2f8e5..cc0c384e2 100644 --- a/apps/web/src/browserPanelStore.ts +++ b/apps/web/src/browserPanelStore.ts @@ -1,9 +1,9 @@ /** * 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 + * Tabs belong to a 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 + * pages. 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. */ @@ -25,14 +25,23 @@ export const BROWSER_VIEWPORT_PRESETS = [ 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. */ +export interface BrowserTab { + id: string; + /** The address loaded, or null for a tab that has not been pointed anywhere. */ url: string | null; + /** The page's own title, used as the tab label once it has one. */ + title: string | null; + /** Per-tab, because the viewport belongs to the thing you are looking at. */ viewportPresetId: BrowserViewportPresetId; } -const BROWSER_PANEL_STORAGE_KEY = "threadlines:browser-panel:v1"; +export interface ThreadBrowserState { + open: boolean; + tabs: BrowserTab[]; + activeTabId: string; +} + +const BROWSER_PANEL_STORAGE_KEY = "threadlines:browser-panel:v2"; /** * Fraction of the split occupied by the chat column. Clamped rather than free: @@ -43,11 +52,43 @@ 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, +let tabSequence = 0; +function nextTabId(): string { + tabSequence += 1; + return `tab-${Date.now().toString(36)}-${tabSequence}`; +} + +export function makeBrowserTab(): BrowserTab { + return { id: nextTabId(), url: null, title: null, viewportPresetId: "fill" }; +} + +/** + * The state a thread has before it has any of its own. + * + * A single frozen value rather than a freshly built one: selectors run on every + * render, and returning a new object each time makes the store look like it + * changed on every read -- React then re-renders forever. It also keeps the + * first tab's id stable, so the tab does not remount the moment the thread + * gains real state. + */ +const DEFAULT_TAB: BrowserTab = Object.freeze({ + id: "tab-initial", url: null, + title: null, viewportPresetId: "fill", -}; +}); + +const DEFAULT_THREAD_STATE: ThreadBrowserState = Object.freeze({ + open: false, + tabs: [DEFAULT_TAB], + activeTabId: DEFAULT_TAB.id, +}); + +const EMPTY_THREAD_STATE: ThreadBrowserState = Object.freeze({ + open: false, + tabs: [], + activeTabId: "", +}); export function clampBrowserSplitFraction(fraction: number): number { if (!Number.isFinite(fraction)) { @@ -59,28 +100,66 @@ export function clampBrowserSplitFraction(fraction: number): number { ); } +/** + * Which tab to select when the active one closes. + * + * The neighbour to the right, falling back to the left at the end of the strip: + * closing several in a row then walks along rather than jumping to an end. + */ +export function nextActiveTabId( + tabs: readonly BrowserTab[], + closedId: string, + activeId: string, +): string | null { + const remaining = tabs.filter((tab) => tab.id !== closedId); + if (remaining.length === 0) { + return null; + } + if (closedId !== activeId) { + return activeId; + } + const closedIndex = tabs.findIndex((tab) => tab.id === closedId); + return (remaining[closedIndex] ?? remaining[remaining.length - 1])!.id; +} + 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; + openTab: (threadRef: ScopedThreadRef) => void; + closeTab: (threadRef: ScopedThreadRef, tabId: string) => void; + selectTab: (threadRef: ScopedThreadRef, tabId: string) => void; + setTabUrl: (threadRef: ScopedThreadRef, tabId: string, url: string) => void; + setTabTitle: (threadRef: ScopedThreadRef, tabId: string, title: string) => void; + setTabViewport: ( + threadRef: ScopedThreadRef, + tabId: string, + presetId: BrowserViewportPresetId, + ) => void; setSplitChatFraction: (fraction: number) => void; } function updateThread( state: BrowserPanelStoreState, threadRef: ScopedThreadRef, - patch: Partial, + update: (current: ThreadBrowserState) => ThreadBrowserState, ): Pick { const key = scopedThreadKey(threadRef); - const current = state.browserStateByThreadKey[key] ?? DEFAULT_THREAD_BROWSER_STATE; + const current = state.browserStateByThreadKey[key] ?? DEFAULT_THREAD_STATE; return { - browserStateByThreadKey: { - ...state.browserStateByThreadKey, - [key]: { ...current, ...patch }, - }, + browserStateByThreadKey: { ...state.browserStateByThreadKey, [key]: update(current) }, + }; +} + +function updateTab( + current: ThreadBrowserState, + tabId: string, + patch: Partial, +): ThreadBrowserState { + return { + ...current, + tabs: current.tabs.map((tab) => (tab.id === tabId ? { ...tab, ...patch } : tab)), }; } @@ -89,15 +168,54 @@ export const useBrowserPanelStore = create()( (set) => ({ browserStateByThreadKey: {}, splitChatFraction: DEFAULT_BROWSER_SPLIT_CHAT_FRACTION, - setBrowserOpen: (threadRef, open) => set((state) => updateThread(state, threadRef, { open })), + setBrowserOpen: (threadRef, open) => + set((state) => updateThread(state, threadRef, (current) => ({ ...current, 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 })), + set((state) => + updateThread(state, threadRef, (current) => ({ ...current, open: !current.open })), + ), + openTab: (threadRef) => + set((state) => + updateThread(state, threadRef, (current) => { + const tab = makeBrowserTab(); + return { ...current, tabs: [...current.tabs, tab], activeTabId: tab.id }; + }), + ), + closeTab: (threadRef, tabId) => + set((state) => + updateThread(state, threadRef, (current) => { + const nextActive = nextActiveTabId(current.tabs, tabId, current.activeTabId); + if (nextActive === null) { + // Never leave the panel with no tab at all: closing the last one + // resets to a blank tab rather than an empty frame. + const replacement = makeBrowserTab(); + return { ...current, tabs: [replacement], activeTabId: replacement.id }; + } + return { + ...current, + tabs: current.tabs.filter((tab) => tab.id !== tabId), + activeTabId: nextActive, + }; + }), + ), + selectTab: (threadRef, tabId) => + set((state) => + updateThread(state, threadRef, (current) => ({ ...current, activeTabId: tabId })), + ), + setTabUrl: (threadRef, tabId, url) => + set((state) => + updateThread(state, threadRef, (current) => updateTab(current, tabId, { url })), + ), + setTabTitle: (threadRef, tabId, title) => + set((state) => + updateThread(state, threadRef, (current) => updateTab(current, tabId, { title })), + ), + setTabViewport: (threadRef, tabId, viewportPresetId) => + set((state) => + updateThread(state, threadRef, (current) => + updateTab(current, tabId, { viewportPresetId }), + ), + ), setSplitChatFraction: (fraction) => set(() => ({ splitChatFraction: clampBrowserSplitFraction(fraction) })), }), @@ -119,7 +237,14 @@ export function selectThreadBrowserState( threadRef: ScopedThreadRef | null, ): ThreadBrowserState { if (threadRef === null) { - return DEFAULT_THREAD_BROWSER_STATE; + return EMPTY_THREAD_STATE; } - return browserStateByThreadKey[scopedThreadKey(threadRef)] ?? DEFAULT_THREAD_BROWSER_STATE; + const stored = browserStateByThreadKey[scopedThreadKey(threadRef)]; + // A thread with no stored entry has one blank tab, so callers never have to + // reason about a panel with nothing in it. + return stored === undefined || stored.tabs.length === 0 ? DEFAULT_THREAD_STATE : stored; +} + +export function selectActiveTab(state: ThreadBrowserState): BrowserTab | null { + return state.tabs.find((tab) => tab.id === state.activeTabId) ?? state.tabs[0] ?? null; } diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx index 5e11cf5e2..ea289da43 100644 --- a/apps/web/src/components/browser/BrowserPanel.tsx +++ b/apps/web/src/components/browser/BrowserPanel.tsx @@ -1,9 +1,12 @@ import type { DesktopLocalServer, ScopedThreadRef } from "@threadlines/contracts"; +import { PREVIEW_PARTITION } from "@threadlines/shared/preview"; import { ArrowLeftIcon, ArrowRightIcon, CameraIcon, ExternalLinkIcon, + GlobeIcon, + PlusIcon, RadioTowerIcon, RotateCwIcon, XIcon, @@ -12,15 +15,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { BROWSER_VIEWPORT_PRESETS, + selectActiveTab, selectThreadBrowserState, useBrowserPanelStore, + type BrowserTab, type BrowserViewportPresetId, } from "../../browserPanelStore"; import { isElectron } from "../../env"; import { cn } from "../../lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { PREVIEW_PARTITION } from "@threadlines/shared/preview"; - import { normalizePreviewUrl } from "./previewUrl"; /** @@ -41,9 +44,24 @@ declare global { } } -interface PreviewWebview extends HTMLElement { +/** + * A rejects imperative calls until its guest has attached and fired + * dom-ready. Navigation still happens: `src` is bound to the tab's url, so a + * tab created and immediately pointed somewhere loads from the attribute once + * it is ready. These helpers keep that race from surfacing as an error. + */ +function callWhenReady(action: () => T): T | null { + try { + return action(); + } catch { + return null; + } +} + +export interface PreviewWebview extends HTMLElement { getWebContentsId: () => number; getURL: () => string; + getTitle: () => string; canGoBack: () => boolean; canGoForward: () => boolean; goBack: () => void; @@ -52,6 +70,14 @@ interface PreviewWebview extends HTMLElement { loadURL: (url: string) => Promise; } +interface NavState { + canGoBack: boolean; + canGoForward: boolean; + loading: boolean; +} + +const IDLE_NAV_STATE: NavState = { canGoBack: false, canGoForward: false, loading: false }; + export function BrowserPanel({ threadRef, flexGrow, @@ -65,100 +91,61 @@ export function BrowserPanel({ const browserState = useBrowserPanelStore((store) => selectThreadBrowserState(store.browserStateByThreadKey, threadRef), ); - const setBrowserUrl = useBrowserPanelStore((store) => store.setBrowserUrl); - const setViewportPreset = useBrowserPanelStore((store) => store.setViewportPreset); + const setTabUrl = useBrowserPanelStore((store) => store.setTabUrl); + const setTabViewport = useBrowserPanelStore((store) => store.setTabViewport); + const openTab = useBrowserPanelStore((store) => store.openTab); + const closeTab = useBrowserPanelStore((store) => store.closeTab); + const selectTab = useBrowserPanelStore((store) => store.selectTab); - const webviewRef = useRef(null); - const [addressDraft, setAddressDraft] = useState(browserState.url ?? ""); - const [isLoading, setIsLoading] = useState(false); - const [navState, setNavState] = useState({ canGoBack: false, canGoForward: false }); + const activeTab = selectActiveTab(browserState); + const activeTabId = activeTab?.id ?? ""; - // The address bar is an input the user types in, so it must not be yanked - // back to the store on every keystroke -- only when the page itself moves. - useEffect(() => { - setAddressDraft(browserState.url ?? ""); - }, [browserState.url]); + // One element per tab, so switching tabs keeps every page alive -- along with + // the CDP attachment collecting its console and network diagnostics. + const webviewsRef = useRef(new Map()); + const [navState, setNavState] = useState(IDLE_NAV_STATE); + const [addressDraft, setAddressDraft] = useState(activeTab?.url ?? ""); + const activeUrl = activeTab?.url ?? null; - // Attach CDP once the guest exists. Done here rather than on first tool call - // so console output and failed requests are being collected from the first - // page load: by the time anyone asks what went wrong, it has already printed. + // The address bar is an input the user types in, so it is only reset when the + // page moves or the tab changes -- never on every keystroke. useEffect(() => { - const webview = webviewRef.current; - if (webview === null || !isElectron) { - return; - } - let attachedId: number | null = null; - const onAttached = () => { - attachedId = webview.getWebContentsId(); - void window.desktopBridge?.previewAttach?.({ webContentsId: attachedId }); - }; - webview.addEventListener("did-attach", onAttached); - return () => { - webview.removeEventListener("did-attach", onAttached); - if (attachedId !== null) { - void window.desktopBridge?.previewDetach?.({ webContentsId: attachedId }); - } - }; - }, []); + setAddressDraft(activeUrl ?? ""); + setNavState(IDLE_NAV_STATE); + }, [activeUrl, activeTabId]); - useEffect(() => { - const webview = webviewRef.current; - if (webview === null) { - return; - } - const syncNav = () => { - setNavState({ canGoBack: webview.canGoBack(), canGoForward: webview.canGoForward() }); - }; - const onNavigated = () => { - syncNav(); - setBrowserUrl(threadRef, webview.getURL()); - }; - const onStart = () => setIsLoading(true); - const onStop = () => { - setIsLoading(false); - syncNav(); - }; - webview.addEventListener("did-navigate", onNavigated); - webview.addEventListener("did-navigate-in-page", onNavigated); - webview.addEventListener("did-start-loading", onStart); - webview.addEventListener("did-stop-loading", onStop); - return () => { - webview.removeEventListener("did-navigate", onNavigated); - webview.removeEventListener("did-navigate-in-page", onNavigated); - webview.removeEventListener("did-start-loading", onStart); - webview.removeEventListener("did-stop-loading", onStop); - }; - }, [setBrowserUrl, threadRef]); + const activeWebview = () => webviewsRef.current.get(activeTabId) ?? null; const submitAddress = useCallback( (event: React.FormEvent) => { event.preventDefault(); const normalized = normalizePreviewUrl(addressDraft); - if (normalized === null) { + if (normalized === null || activeTabId === "") { return; } - setBrowserUrl(threadRef, normalized); - void webviewRef.current?.loadURL(normalized); + setTabUrl(threadRef, activeTabId, normalized); + const webview = webviewsRef.current.get(activeTabId); + callWhenReady(() => void webview?.loadURL(normalized)); }, - [addressDraft, setBrowserUrl, threadRef], + [activeTabId, addressDraft, setTabUrl, threadRef], ); const captureScreenshot = useCallback(() => { - const webview = webviewRef.current; - if (webview === null || !isElectron) { + const webview = webviewsRef.current.get(activeTabId); + if (webview === undefined || !isElectron) { return; } void window.desktopBridge ?.previewScreenshot?.({ webContentsId: webview.getWebContentsId() }) .then((shot) => { - if (shot?.dataUrl) { + if (shot.dataUrl !== "") { void navigator.clipboard.writeText(shot.dataUrl).catch(() => {}); } }); - }, []); + }, [activeTabId]); const preset = - BROWSER_VIEWPORT_PRESETS.find((entry) => entry.id === browserState.viewportPresetId) ?? + BROWSER_VIEWPORT_PRESETS.find((entry) => entry.id === activeTab?.viewportPresetId) ?? BROWSER_VIEWPORT_PRESETS[0]; return ( @@ -168,23 +155,45 @@ export function BrowserPanel({ data-testid="browser-panel" aria-label="Browser preview" > +
+ {browserState.tabs.map((tab) => ( + 1} + onSelect={() => selectTab(threadRef, tab.id)} + onClose={() => closeTab(threadRef, tab.id)} + /> + ))} + +
+
webviewRef.current?.goBack()} + onClick={() => activeWebview()?.goBack()} > webviewRef.current?.goForward()} + onClick={() => activeWebview()?.goForward()} > - webviewRef.current?.reload()}> - + activeWebview()?.reload()}> +
@@ -192,7 +201,7 @@ export function BrowserPanel({ aria-label="Address" data-testid="browser-panel-address" className="w-full truncate rounded-md border border-border bg-background px-2 py-1 font-mono text-[11px] text-muted-foreground outline-none focus:border-ring focus:text-foreground" - placeholder="localhost:5173" + placeholder="Search or enter URL" value={addressDraft} onChange={(event) => setAddressDraft(event.target.value)} /> @@ -202,10 +211,12 @@ export function BrowserPanel({ aria-label="Viewport" data-testid="browser-panel-viewport" className="shrink-0 rounded-md border border-border bg-background px-1.5 py-1 font-mono text-[10px] text-muted-foreground outline-none focus:border-ring" - value={browserState.viewportPresetId} - onChange={(event) => - setViewportPreset(threadRef, event.target.value as BrowserViewportPresetId) - } + value={activeTab?.viewportPresetId ?? "fill"} + onChange={(event) => { + if (activeTabId !== "") { + setTabViewport(threadRef, activeTabId, event.target.value as BrowserViewportPresetId); + } + }} > {BROWSER_VIEWPORT_PRESETS.map((entry) => (
-
- {isElectron && browserState.url === null ? ( - { - const url = `http://localhost:${port}`; - setBrowserUrl(threadRef, url); - void webviewRef.current?.loadURL(url); - }} - /> - ) : null} - {isElectron ? ( -
); } + +/** + * 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. */} +
{/* Chat column */}
{/* Messages Wrapper */} @@ -6401,10 +6412,16 @@ export default function ChatView(props: ChatViewProps) { {/* end chat column */} {browserOpen && routeThreadRef !== null ? ( <> - + {browserExpanded ? null : ( + + )} diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx index ea289da43..c442b9b9f 100644 --- a/apps/web/src/components/browser/BrowserPanel.tsx +++ b/apps/web/src/components/browser/BrowserPanel.tsx @@ -6,6 +6,11 @@ import { CameraIcon, ExternalLinkIcon, GlobeIcon, + MaximizeIcon, + MinimizeIcon, + MoreVerticalIcon, + PanelBottomIcon, + PanelRightIcon, PlusIcon, RadioTowerIcon, RotateCwIcon, @@ -23,6 +28,7 @@ import { } from "../../browserPanelStore"; import { isElectron } from "../../env"; import { cn } from "../../lib/utils"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { normalizePreviewUrl } from "./previewUrl"; @@ -96,6 +102,10 @@ export function BrowserPanel({ const openTab = useBrowserPanelStore((store) => store.openTab); const closeTab = useBrowserPanelStore((store) => store.closeTab); const selectTab = useBrowserPanelStore((store) => store.selectTab); + const dockSide = useBrowserPanelStore((store) => store.dockSide); + const expanded = useBrowserPanelStore((store) => store.expanded); + const setDockSide = useBrowserPanelStore((store) => store.setDockSide); + const toggleExpanded = useBrowserPanelStore((store) => store.toggleExpanded); const activeTab = selectActiveTab(browserState); const activeTabId = activeTab?.id ?? ""; @@ -150,7 +160,11 @@ export function BrowserPanel({ return (
+ + {/* Where the panel lives belongs with the tabs, not with the controls + that act on the page. */} +
+ + {expanded ? ( + + ) : ( + + )} + + setDockSide("bottom")} + active={dockSide === "bottom"} + > + + + setDockSide("right")} + active={dockSide === "right"} + > + + +
@@ -228,6 +272,70 @@ export function BrowserPanel({ + + + + } + > + + + + { + if (activeUrl !== null) { + void window.desktopBridge?.openExternal?.(activeUrl); + } + }} + > + Open in default browser + + { + if (activeUrl !== null) { + void navigator.clipboard.writeText(activeUrl).catch(() => {}); + } + }} + > + Copy address + + { + const webview = webviewsRef.current.get(activeTabId); + const id = + webview === undefined ? null : callWhenReady(() => webview.getWebContentsId()); + if (id !== null) { + void window.desktopBridge?.previewOpenDevTools?.({ webContentsId: id }); + } + }} + > + Open developer tools + + + { + // Signing out of the preview is the point, so reload after: the + // page on screen would otherwise still look signed in. + void window.desktopBridge?.previewClearBrowsingData?.().then(() => { + callWhenReady(() => webviewsRef.current.get(activeTabId)?.reload()); + }); + }} + > + Clear cookies and storage + + + + @@ -430,11 +538,13 @@ function PreviewTabFrame({ function NavButton({ label, disabled, + active, onClick, children, }: { label: string; disabled?: boolean; + active?: boolean; onClick: () => void; children: React.ReactNode; }) { @@ -447,7 +557,10 @@ function NavButton({ aria-label={label} disabled={disabled} onClick={onClick} - className="inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40" + className={cn( + "inline-flex size-6 shrink-0 items-center justify-center rounded-md hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40", + active === true ? "bg-accent text-foreground" : "text-muted-foreground/70", + )} /> } > diff --git a/apps/web/src/components/browser/BrowserSplitHandle.tsx b/apps/web/src/components/browser/BrowserSplitHandle.tsx index d46697dbe..b0ec13ac0 100644 --- a/apps/web/src/components/browser/BrowserSplitHandle.tsx +++ b/apps/web/src/components/browser/BrowserSplitHandle.tsx @@ -1,6 +1,7 @@ import { useCallback, useRef } from "react"; import { clampBrowserSplitFraction } from "../../browserPanelStore"; +import { cn } from "../../lib/utils"; /** * Drag handle between the chat column and the browser panel. @@ -12,9 +13,12 @@ import { clampBrowserSplitFraction } from "../../browserPanelStore"; export function BrowserSplitHandle({ chatFraction, onChange, + orientation = "vertical", }: { chatFraction: number; onChange: (fraction: number) => void; + /** "vertical" splits left/right; "horizontal" splits top/bottom. */ + orientation?: "vertical" | "horizontal"; }) { const draggingRef = useRef(false); @@ -33,12 +37,15 @@ export function BrowserSplitHandle({ return; } const bounds = row.getBoundingClientRect(); - if (bounds.width === 0) { + const extent = orientation === "vertical" ? bounds.width : bounds.height; + if (extent === 0) { return; } - onChange(clampBrowserSplitFraction((event.clientX - bounds.left) / bounds.width)); + const offset = + orientation === "vertical" ? event.clientX - bounds.left : event.clientY - bounds.top; + onChange(clampBrowserSplitFraction(offset / extent)); }, - [onChange], + [onChange, orientation], ); const endDrag = useCallback((event: React.PointerEvent) => { @@ -51,26 +58,33 @@ export function BrowserSplitHandle({ const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { const step = event.shiftKey ? 0.1 : 0.02; - if (event.key === "ArrowLeft") { + const decrease = orientation === "vertical" ? "ArrowLeft" : "ArrowUp"; + const increase = orientation === "vertical" ? "ArrowRight" : "ArrowDown"; + if (event.key === decrease) { event.preventDefault(); onChange(clampBrowserSplitFraction(chatFraction - step)); - } else if (event.key === "ArrowRight") { + } else if (event.key === increase) { event.preventDefault(); onChange(clampBrowserSplitFraction(chatFraction + step)); } }, - [chatFraction, onChange], + [chatFraction, onChange, orientation], ); return (
Promise; previewLocalServers?: () => Promise; previewScreenshot?: (input: DesktopPreviewTarget) => Promise; + previewOpenDevTools?: (input: DesktopPreviewTarget) => Promise; + previewClearBrowsingData?: () => Promise; setTheme: (theme: DesktopTheme) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], From 0e41d70399f7defe53a87bdbd51eb5106bb5d94a Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:38:54 -0400 Subject: [PATCH 29/82] Match the preview to the app's appearance, and keep it in the sidebar Three things, all visible the moment a dark page loaded. The guest was painted white. That was a literal bg-white on the element, so a page with a dark background sat inside a white frame and the empty state -- which overlays the guest -- rendered our light-on-dark text onto white, leaving it nearly unreadable. The guest now uses the app's background and the empty state paints its own, so neither depends on what the page underneath happens to be. The white line around a dark page was the same white showing at the element's edges past the page's own painting. Gone with it. Pages that respect prefers-color-scheme now follow the app rather than the OS, emulated through CDP so a page that ignores the query is left alone. A dark app hosting a stubbornly light page is the jarring part, and it is the app the page is embedded in. Docking to the bottom is removed. The bottom edge belongs to the terminal, and a browser that could take it made two things compete for one place. The browser splits the centre; that is the whole story. --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/preview.ts | 11 ++++ apps/desktop/src/preload.ts | 2 + apps/desktop/src/preview/PreviewAutomation.ts | 15 +++++ apps/web/src/browserPanelStore.ts | 14 ----- apps/web/src/components/ChatView.tsx | 13 +---- .../src/components/browser/BrowserPanel.tsx | 56 ++++++++++--------- packages/contracts/src/ipc.ts | 7 +++ 9 files changed, 71 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 36d46753d..23336546a 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -57,6 +57,7 @@ import { previewClearBrowsingData, previewOpenDevTools, previewScreenshot, + previewSetColorScheme, previewSnapshot, previewStatus, previewType, @@ -95,6 +96,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(previewLocalServers); yield* ipc.handle(previewScreenshot); yield* ipc.handle(previewOpenDevTools); + yield* ipc.handle(previewSetColorScheme); yield* ipc.handle(previewClearBrowsingData); yield* ipc.handle(getServerExposureState); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 7c3ddd12e..72aa8159d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -11,6 +11,7 @@ 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_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; 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"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 7052dcfc2..55bf72bea 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,6 +1,7 @@ import { DesktopLocalServerSchema, DesktopPreviewClickInputSchema, + DesktopPreviewColorSchemeInputSchema, DesktopPreviewEvaluateInputSchema, DesktopPreviewScreenshotSchema, DesktopPreviewSnapshotSchema, @@ -126,3 +127,13 @@ export const previewClearBrowsingData = makeIpcMethod({ yield* session.clearBrowsingData(); }), }); + +export const previewSetColorScheme = makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, + payload: DesktopPreviewColorSchemeInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setColorScheme")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + yield* automation.setColorScheme(input.webContentsId, input.colorScheme); + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d240b7239..108a9a84a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -120,6 +120,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { previewScreenshot: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_SCREENSHOT_CHANNEL, input), previewOpenDevTools: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, input), + previewSetColorScheme: (input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, input), previewClearBrowsingData: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_BROWSING_DATA_CHANNEL), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), diff --git a/apps/desktop/src/preview/PreviewAutomation.ts b/apps/desktop/src/preview/PreviewAutomation.ts index 32b7be95a..b6258f7dd 100644 --- a/apps/desktop/src/preview/PreviewAutomation.ts +++ b/apps/desktop/src/preview/PreviewAutomation.ts @@ -120,6 +120,10 @@ export class PreviewAutomation extends Context.Service< webContentsId: number, ) => Effect.Effect; readonly openDevTools: (webContentsId: number) => Effect.Effect; + readonly setColorScheme: ( + webContentsId: number, + colorScheme: "light" | "dark", + ) => Effect.Effect; readonly screenshot: ( webContentsId: number, ) => Effect.Effect; @@ -373,6 +377,17 @@ export const make = Effect.gen(function* PreviewAutomationMake() { } return { ...buildStatus(webContentsId, contents), elements }; }), + setColorScheme: Effect.fn("PreviewAutomation.setColorScheme")(function* ( + webContentsId: number, + colorScheme: "light" | "dark", + ) { + const contents = yield* resolve(webContentsId); + // Emulated rather than set on the guest: the page decides what to do with + // prefers-color-scheme, and pages that ignore it are left alone. + yield* sendCommand(contents, "Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: colorScheme }], + }); + }), 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 diff --git a/apps/web/src/browserPanelStore.ts b/apps/web/src/browserPanelStore.ts index 2de8bec81..ff434d3f8 100644 --- a/apps/web/src/browserPanelStore.ts +++ b/apps/web/src/browserPanelStore.ts @@ -52,15 +52,6 @@ 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; @@ -134,7 +125,6 @@ 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; @@ -150,7 +140,6 @@ interface BrowserPanelStoreState { presetId: BrowserViewportPresetId, ) => void; setSplitChatFraction: (fraction: number) => void; - setDockSide: (side: BrowserDockSide) => void; toggleExpanded: () => void; } @@ -182,7 +171,6 @@ 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 }))), @@ -234,7 +222,6 @@ export const useBrowserPanelStore = create()( ), setSplitChatFraction: (fraction) => set(() => ({ splitChatFraction: clampBrowserSplitFraction(fraction) })), - setDockSide: (side) => set(() => ({ dockSide: side })), toggleExpanded: () => set((state) => ({ expanded: !state.expanded })), }), { @@ -245,7 +232,6 @@ 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 e72178374..9b0853ba2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2514,7 +2514,6 @@ 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; @@ -6124,14 +6123,9 @@ export default function ChatView(props: ChatViewProps) { void confirmRevertThread(); }} /> - {/* Main content area. The browser docks beside or below the chat; when - expanded it takes the whole area and the chat is not rendered. */} -
+ {/* Main content area. The browser splits the centre beside the chat; the + bottom edge belongs to the terminal. Expanding hides the chat. */} +
{/* Chat column */}
)} store.openTab); const closeTab = useBrowserPanelStore((store) => store.closeTab); const selectTab = useBrowserPanelStore((store) => store.selectTab); - const dockSide = useBrowserPanelStore((store) => store.dockSide); const expanded = useBrowserPanelStore((store) => store.expanded); - const setDockSide = useBrowserPanelStore((store) => store.setDockSide); const toggleExpanded = useBrowserPanelStore((store) => store.toggleExpanded); + const { resolvedTheme } = useTheme(); const activeTab = selectActiveTab(browserState); const activeTabId = activeTab?.id ?? ""; @@ -160,11 +158,7 @@ export function BrowserPanel({ return (
)} - setDockSide("bottom")} - active={dockSide === "bottom"} - > - - - setDockSide("right")} - active={dockSide === "right"} - > - -
@@ -347,7 +327,7 @@ export function BrowserPanel({ ) : ( <> {activeTab !== null && activeTab.url === null ? ( -
+
{ 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…"}

) : ( @@ -623,12 +623,13 @@ function LocalServerPicker({ onSelect }: { onSelect: (port: number) => void }) { key={server.port} type="button" data-testid={`local-server-${server.port}`} - className="flex items-center gap-3 border-b border-border/60 px-1 py-2.5 text-left hover:bg-accent" + className="flex items-center gap-3 rounded-md border-b border-border/60 px-2 py-2.5 text-left hover:bg-accent" onClick={() => onSelect(server.port)} > - {server.processName === "" ? "Unknown process" : server.processName} + {server.title ?? + (server.processName === "" ? "Unknown server" : server.processName)} localhost:{server.port} @@ -639,7 +640,7 @@ function LocalServerPicker({ onSelect }: { onSelect: (port: number) => void }) { ))}
)} -

+

Select a listening port to open it here.

diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index c2c6760ff..4962f9e9c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -451,6 +451,8 @@ export const DesktopLocalServerSchema = Schema.Struct({ port: Schema.Number, processName: Schema.String, pid: Schema.Number, + /** The served page's title, which identifies a server better than "node". */ + title: Schema.NullOr(Schema.String), }); export type DesktopLocalServer = typeof DesktopLocalServerSchema.Type; From 4e0d9d9018f044fb9928357af5ab4b70dafc5986 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:58:30 -0400 Subject: [PATCH 31/82] Move device sizing out of the toolbar and into a device row The size picker sat in the toolbar on every page you ever looked at, for a task done a few times a session. It now lives behind "Show device toolbar", the way the rest of the browser's occasional controls do, and the toolbar keeps its width for the address. Sizes are a width and a height you can type, not a fixed menu of devices. The presets fill them in, editing either switches to Custom, rotate swaps them, and clearing a field returns to responsive -- so an arbitrary size costs nothing extra and the presets stay as shortcuts. Also fills out the menu: hard reload, an appearance override for pages whose colour scheme you want to check independently of the app, zoom in familiar steps, and clearing the cache separately from cookies. A stale bundle is a different complaint from being signed in, and clearing both when asked for one loses work. Verified in the desktop app: iPhone 15 gives 393x852, rotate gives 852x393, typing 500 gives 500x393 and the preset reads Custom. --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/preview.ts | 10 + apps/desktop/src/preload.ts | 1 + apps/desktop/src/preview/PreviewSession.ts | 10 + apps/web/src/browserPanelStore.test.ts | 20 +- apps/web/src/browserPanelStore.ts | 81 ++++- .../src/components/browser/BrowserPanel.tsx | 280 ++++++++++++++++-- packages/contracts/src/ipc.ts | 1 + 9 files changed, 359 insertions(+), 47 deletions(-) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 23336546a..344374af1 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -55,6 +55,7 @@ import { previewEvaluate, previewLocalServers, previewClearBrowsingData, + previewClearCache, previewOpenDevTools, previewScreenshot, previewSetColorScheme, @@ -98,6 +99,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(previewOpenDevTools); yield* ipc.handle(previewSetColorScheme); yield* ipc.handle(previewClearBrowsingData); + yield* ipc.handle(previewClearCache); 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 72aa8159d..26086585a 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -13,6 +13,7 @@ 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_CLEAR_BROWSING_DATA_CHANNEL = "desktop:preview-clear-browsing-data"; +export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; 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 55bf72bea..29d6eda2c 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -137,3 +137,13 @@ export const previewSetColorScheme = makeIpcMethod({ yield* automation.setColorScheme(input.webContentsId, input.colorScheme); }), }); + +export const previewClearCache = makeIpcMethod({ + channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () { + const session = yield* PreviewSession.PreviewSession; + yield* session.clearCache(); + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 108a9a84a..14a216023 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -124,6 +124,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, input), previewClearBrowsingData: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_BROWSING_DATA_CHANNEL), + previewClearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_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/PreviewSession.ts b/apps/desktop/src/preview/PreviewSession.ts index 66a917e87..ee9bd5a4d 100644 --- a/apps/desktop/src/preview/PreviewSession.ts +++ b/apps/desktop/src/preview/PreviewSession.ts @@ -49,6 +49,7 @@ export class PreviewSession extends Context.Service< readonly partition: string; readonly getSession: () => Effect.Effect; readonly clearBrowsingData: () => Effect.Effect; + readonly clearCache: () => Effect.Effect; } >()("@threadlines/desktop/preview/PreviewSession") {} @@ -89,6 +90,15 @@ export const make = Effect.gen(function* PreviewSessionMake() { return PreviewSession.of({ partition: PREVIEW_PARTITION, getSession, + clearCache: Effect.fn("PreviewSession.clearCache")(function* () { + // Cache only: a stale bundle is a different complaint from being signed + // in, and clearing both when asked for one loses work. + const previewSession = yield* getSession(); + yield* Effect.tryPromise({ + try: () => previewSession.clearCache(), + catch: (cause) => new PreviewSessionCreationError({ partition: PREVIEW_PARTITION, cause }), + }); + }), clearBrowsingData: Effect.fn("PreviewSession.clearBrowsingData")(function* () { const previewSession = yield* getSession(); yield* Effect.tryPromise({ diff --git a/apps/web/src/browserPanelStore.test.ts b/apps/web/src/browserPanelStore.test.ts index b31e1962e..24f6eae60 100644 --- a/apps/web/src/browserPanelStore.test.ts +++ b/apps/web/src/browserPanelStore.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { makeBrowserTab, nextActiveTabId, type BrowserTab } from "./browserPanelStore"; +import { makeBrowserTab, nextActiveTabId, steppedZoom, type BrowserTab } from "./browserPanelStore"; function tabs(count: number): BrowserTab[] { return Array.from({ length: count }, () => makeBrowserTab()); @@ -31,3 +31,21 @@ describe("nextActiveTabId", () => { expect(nextActiveTabId([only], only.id, only.id)).toBeNull(); }); }); + +describe("steppedZoom", () => { + it("moves to the next familiar step rather than drifting", () => { + expect(steppedZoom(1, 1)).toBe(1.1); + expect(steppedZoom(1, -1)).toBe(0.9); + expect(steppedZoom(1.1, 1)).toBe(1.25); + }); + + it("stops at the ends instead of running away", () => { + expect(steppedZoom(2, 1)).toBe(2); + expect(steppedZoom(0.5, -1)).toBe(0.5); + }); + + it("snaps a value between steps onto the grid", () => { + expect(steppedZoom(1.05, 1)).toBe(1.1); + expect(steppedZoom(1.05, -1)).toBe(1); + }); +}); diff --git a/apps/web/src/browserPanelStore.ts b/apps/web/src/browserPanelStore.ts index ff434d3f8..e18d21600 100644 --- a/apps/web/src/browserPanelStore.ts +++ b/apps/web/src/browserPanelStore.ts @@ -15,15 +15,27 @@ 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. */ +/** + * Named sizes, as shortcuts for filling in a width and height rather than as + * the only way to choose one. Any size is reachable by typing it. + */ 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 }, + { label: "Responsive", width: null, height: null }, + { label: "iPhone 15", width: 393, height: 852 }, + { label: "iPad", width: 834, height: 1112 }, + { label: "Laptop", width: 1280, height: 800 }, ] as const; -export type BrowserViewportPresetId = (typeof BROWSER_VIEWPORT_PRESETS)[number]["id"]; +/** null dimensions mean the page fills the panel and reflows with it. */ +export interface BrowserViewport { + width: number | null; + height: number | null; +} + +export const RESPONSIVE_VIEWPORT: BrowserViewport = Object.freeze({ width: null, height: null }); + +/** Follows the app unless deliberately overridden for the page under test. */ +export type BrowserAppearance = "system" | "light" | "dark"; export interface BrowserTab { id: string; @@ -32,7 +44,8 @@ export interface BrowserTab { /** The page's own title, used as the tab label once it has one. */ title: string | null; /** Per-tab, because the viewport belongs to the thing you are looking at. */ - viewportPresetId: BrowserViewportPresetId; + viewport: BrowserViewport; + zoomFactor: number; } export interface ThreadBrowserState { @@ -59,7 +72,7 @@ function nextTabId(): string { } export function makeBrowserTab(): BrowserTab { - return { id: nextTabId(), url: null, title: null, viewportPresetId: "fill" }; + return { id: nextTabId(), url: null, title: null, viewport: RESPONSIVE_VIEWPORT, zoomFactor: 1 }; } /** @@ -75,7 +88,8 @@ const DEFAULT_TAB: BrowserTab = Object.freeze({ id: "tab-initial", url: null, title: null, - viewportPresetId: "fill", + viewport: RESPONSIVE_VIEWPORT, + zoomFactor: 1, }); const DEFAULT_THREAD_STATE: ThreadBrowserState = Object.freeze({ @@ -90,6 +104,25 @@ const EMPTY_THREAD_STATE: ThreadBrowserState = Object.freeze({ activeTabId: "", }); +/** Chrome's range, so the familiar steps land on familiar numbers. */ +export const ZOOM_STEPS = [0.5, 0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2] as const; + +export function clampZoomFactor(factor: number): number { + if (!Number.isFinite(factor)) { + return 1; + } + return Math.min(2, Math.max(0.5, factor)); +} + +/** The next step up or down, so zooming lands on round values rather than drifting. */ +export function steppedZoom(current: number, direction: 1 | -1): number { + const steps = direction === 1 ? ZOOM_STEPS : [...ZOOM_STEPS].reverse(); + const next = steps.find((step) => + direction === 1 ? step > current + 0.001 : step < current - 0.001, + ); + return next ?? clampZoomFactor(current); +} + export function clampBrowserSplitFraction(fraction: number): number { if (!Number.isFinite(fraction)) { return DEFAULT_BROWSER_SPLIT_CHAT_FRACTION; @@ -127,6 +160,13 @@ interface BrowserPanelStoreState { splitChatFraction: number; /** Hides the chat so the page gets the whole centre; the split is remembered. */ expanded: boolean; + /** + * The device row is off by default. Sizing is an occasional task, and a + * permanent control for it costs toolbar width on every page you ever look + * at, which is the wrong trade for something used a few times a session. + */ + deviceToolbarOpen: boolean; + appearance: BrowserAppearance; setBrowserOpen: (threadRef: ScopedThreadRef, open: boolean) => void; toggleBrowserOpen: (threadRef: ScopedThreadRef) => void; openTab: (threadRef: ScopedThreadRef) => void; @@ -134,11 +174,10 @@ interface BrowserPanelStoreState { selectTab: (threadRef: ScopedThreadRef, tabId: string) => void; setTabUrl: (threadRef: ScopedThreadRef, tabId: string, url: string) => void; setTabTitle: (threadRef: ScopedThreadRef, tabId: string, title: string) => void; - setTabViewport: ( - threadRef: ScopedThreadRef, - tabId: string, - presetId: BrowserViewportPresetId, - ) => void; + setTabViewport: (threadRef: ScopedThreadRef, tabId: string, viewport: BrowserViewport) => void; + setTabZoom: (threadRef: ScopedThreadRef, tabId: string, zoomFactor: number) => void; + toggleDeviceToolbar: () => void; + setAppearance: (appearance: BrowserAppearance) => void; setSplitChatFraction: (fraction: number) => void; toggleExpanded: () => void; } @@ -172,6 +211,8 @@ export const useBrowserPanelStore = create()( browserStateByThreadKey: {}, splitChatFraction: DEFAULT_BROWSER_SPLIT_CHAT_FRACTION, expanded: false, + deviceToolbarOpen: false, + appearance: "system", setBrowserOpen: (threadRef, open) => set((state) => updateThread(state, threadRef, (current) => ({ ...current, open }))), toggleBrowserOpen: (threadRef) => @@ -214,12 +255,18 @@ export const useBrowserPanelStore = create()( set((state) => updateThread(state, threadRef, (current) => updateTab(current, tabId, { title })), ), - setTabViewport: (threadRef, tabId, viewportPresetId) => + setTabViewport: (threadRef, tabId, viewport) => + set((state) => + updateThread(state, threadRef, (current) => updateTab(current, tabId, { viewport })), + ), + setTabZoom: (threadRef, tabId, zoomFactor) => set((state) => updateThread(state, threadRef, (current) => - updateTab(current, tabId, { viewportPresetId }), + updateTab(current, tabId, { zoomFactor: clampZoomFactor(zoomFactor) }), ), ), + toggleDeviceToolbar: () => set((state) => ({ deviceToolbarOpen: !state.deviceToolbarOpen })), + setAppearance: (appearance) => set(() => ({ appearance })), setSplitChatFraction: (fraction) => set(() => ({ splitChatFraction: clampBrowserSplitFraction(fraction) })), toggleExpanded: () => set((state) => ({ expanded: !state.expanded })), @@ -232,6 +279,8 @@ export const useBrowserPanelStore = create()( partialize: (state) => ({ browserStateByThreadKey: state.browserStateByThreadKey, splitChatFraction: state.splitChatFraction, + deviceToolbarOpen: state.deviceToolbarOpen, + appearance: state.appearance, // 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/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx index 29f3bb099..e6cc7729a 100644 --- a/apps/web/src/components/browser/BrowserPanel.tsx +++ b/apps/web/src/components/browser/BrowserPanel.tsx @@ -10,6 +10,7 @@ import { MinimizeIcon, MoreVerticalIcon, PlusIcon, + RotateCwSquareIcon, RadioTowerIcon, RotateCwIcon, XIcon, @@ -18,16 +19,28 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { BROWSER_VIEWPORT_PRESETS, + RESPONSIVE_VIEWPORT, selectActiveTab, selectThreadBrowserState, + steppedZoom, useBrowserPanelStore, + type BrowserAppearance, type BrowserTab, - type BrowserViewportPresetId, + type BrowserViewport, } from "../../browserPanelStore"; import { isElectron } from "../../env"; import { useTheme } from "../../hooks/useTheme"; import { cn } from "../../lib/utils"; -import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; +import { + Menu, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { normalizePreviewUrl } from "./previewUrl"; @@ -72,6 +85,8 @@ export interface PreviewWebview extends HTMLElement { goBack: () => void; goForward: () => void; reload: () => void; + reloadIgnoringCache: () => void; + setZoomFactor: (factor: number) => void; loadURL: (url: string) => Promise; } @@ -103,7 +118,13 @@ export function BrowserPanel({ const selectTab = useBrowserPanelStore((store) => store.selectTab); const expanded = useBrowserPanelStore((store) => store.expanded); const toggleExpanded = useBrowserPanelStore((store) => store.toggleExpanded); + const deviceToolbarOpen = useBrowserPanelStore((store) => store.deviceToolbarOpen); + const toggleDeviceToolbar = useBrowserPanelStore((store) => store.toggleDeviceToolbar); + const appearance = useBrowserPanelStore((store) => store.appearance); + const setAppearance = useBrowserPanelStore((store) => store.setAppearance); + const setTabZoom = useBrowserPanelStore((store) => store.setTabZoom); const { resolvedTheme } = useTheme(); + const guestColorScheme = appearance === "system" ? resolvedTheme : appearance; const activeTab = selectActiveTab(browserState); const activeTabId = activeTab?.id ?? ""; @@ -152,10 +173,6 @@ export function BrowserPanel({ }); }, [activeTabId]); - const preset = - BROWSER_VIEWPORT_PRESETS.find((entry) => entry.id === activeTab?.viewportPresetId) ?? - BROWSER_VIEWPORT_PRESETS[0]; - return (
- - @@ -267,6 +266,32 @@ export function BrowserPanel({ + { + const webview = webviewsRef.current.get(activeTabId); + callWhenReady(() => webview?.reloadIgnoringCache()); + }} + > + Hard reload + + + {deviceToolbarOpen ? "Hide device toolbar" : "Show device toolbar"} + + + setAppearance(value as BrowserAppearance)} + > + {/* The label belongs to the group: Base UI reads its context, and + outside one it throws and takes the whole menu with it. */} + Appearance + {(["system", "light", "dark"] as const).map((value) => ( + + {value === "system" ? "Follow app" : value === "light" ? "Light" : "Dark"} + + ))} + + { @@ -301,6 +326,16 @@ export function BrowserPanel({ Open developer tools + { + void window.desktopBridge?.previewClearCache?.().then(() => { + callWhenReady(() => webviewsRef.current.get(activeTabId)?.reloadIgnoringCache()); + }); + }} + > + Clear cache + { @@ -321,6 +356,16 @@ export function BrowserPanel({
+ {deviceToolbarOpen && activeTab !== null ? ( + setTabViewport(threadRef, activeTab.id, viewport)} + onZoomChange={(factor) => setTabZoom(threadRef, activeTab.id, factor)} + onClose={toggleDeviceToolbar} + /> + ) : null} +
{!isElectron ? ( @@ -344,8 +389,9 @@ export function BrowserPanel({ tab={tab} threadRef={threadRef} isActive={tab.id === activeTabId} - preset={preset} - colorScheme={resolvedTheme} + viewport={tab.viewport} + zoomFactor={tab.zoomFactor} + colorScheme={guestColorScheme} onNavState={setNavState} register={(element) => { if (element === null) { @@ -421,7 +467,8 @@ function PreviewTabFrame({ tab, threadRef, isActive, - preset, + viewport, + zoomFactor, colorScheme, onNavState, register, @@ -429,7 +476,8 @@ function PreviewTabFrame({ tab: BrowserTab; threadRef: ScopedThreadRef; isActive: boolean; - preset: (typeof BROWSER_VIEWPORT_PRESETS)[number]; + viewport: BrowserViewport; + zoomFactor: number; colorScheme: "light" | "dark"; onNavState: (state: NavState) => void; register: (element: PreviewWebview | null) => void; @@ -453,6 +501,14 @@ function PreviewTabFrame({ } }, [colorScheme]); + useEffect(() => { + const webview = elementRef.current; + if (webview === null || !isElectron) { + return; + } + callWhenReady(() => webview.setZoomFactor(zoomFactor)); + }, [zoomFactor]); + useEffect(() => { const webview = elementRef.current; if (webview === null || !isElectron) { @@ -528,9 +584,9 @@ function PreviewTabFrame({ {...(isActive ? { "data-testid": "browser-panel-webview" } : {})} className="h-full w-full bg-background" style={ - preset.width === null + viewport.width === null ? undefined - : { width: `${preset.width}px`, height: `${preset.height}px`, flex: "none" } + : { width: `${viewport.width}px`, height: `${viewport.height ?? 800}px`, flex: "none" } } partition={PREVIEW_PARTITION} {...(tab.url ? { src: tab.url } : {})} @@ -664,3 +720,167 @@ function BrowserUnavailableNotice() {
); } + +/** + * 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 ( +
+ + + + onViewportChange( + width === null ? RESPONSIVE_VIEWPORT : { width, height: viewport.height ?? 800 }, + ) + } + /> + × + + onViewportChange( + height === null ? RESPONSIVE_VIEWPORT : { width: viewport.width ?? 1280, height }, + ) + } + /> + + + +
+ + + + +
+
+ ); +} + +/** Empty means "let it fill", which is how you get back to responsive by typing. */ +function DimensionInput({ + label, + value, + onCommit, +}: { + label: string; + value: number | null; + onCommit: (value: number | null) => void; +}) { + const [draft, setDraft] = useState(value === null ? "" : String(value)); + useEffect(() => { + setDraft(value === null ? "" : String(value)); + }, [value]); + + const commit = () => { + const trimmed = draft.trim(); + if (trimmed === "") { + onCommit(null); + return; + } + const parsed = Number.parseInt(trimmed, 10); + onCommit(Number.isNaN(parsed) || parsed < 1 ? value : Math.min(parsed, 9999)); + }; + + return ( + setDraft(event.target.value)} + onBlur={commit} + onKeyDown={(event) => { + if (event.key === "Enter") { + commit(); + } + }} + /> + ); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4962f9e9c..d7b4d0a85 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -727,6 +727,7 @@ export interface DesktopBridge { previewOpenDevTools?: (input: DesktopPreviewTarget) => Promise; previewSetColorScheme?: (input: DesktopPreviewColorSchemeInput) => Promise; previewClearBrowsingData?: () => Promise; + previewClearCache?: () => Promise; setTheme: (theme: DesktopTheme) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], From 47d18c60c2cd02edb6b6d95963174aad5064be2e Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:16:24 -0400 Subject: [PATCH 32/82] Make the device frame draggable, and tell the page it resized Dragging the page's own edges is the natural way to find a breakpoint, and it means not resizing the whole app around the question. Handles on the right, bottom and corner, with a shield raised while dragging because the pointer crosses the guest, which is a separate process and would otherwise swallow the events. Opening the row seeds concrete dimensions from what the page currently occupies, so there is something to drag immediately rather than an "auto" that does nothing. Sizing the element alone was not enough, and looked broken: the page kept believing it was the old width, so a narrow frame clipped a desktop layout instead of showing the mobile one. The metrics are now overridden through CDP, which is what device mode actually is -- media queries re-evaluate and innerWidth reports the emulated size. Verified: an iPhone 15 frame makes the page report 393x852. Zoom is reasserted on attach and on navigation. Electron remembers it per origin inside the session, so a page visited at 125% came back at 125% while our control still read 100%. The tab row now carries screenshot, overflow, expand and close, leaving the address row for controls that act on the page -- annotate and element-pick will want that space. The device row is centred, and lost its label, which cost more width than it explained. --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/preview.ts | 14 + apps/desktop/src/preload.ts | 2 + apps/desktop/src/preview/PreviewAutomation.ts | 26 ++ .../src/components/browser/BrowserPanel.tsx | 431 ++++++++++++------ packages/contracts/src/ipc.ts | 9 + 7 files changed, 354 insertions(+), 131 deletions(-) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 344374af1..d7d4484b9 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -59,6 +59,7 @@ import { previewOpenDevTools, previewScreenshot, previewSetColorScheme, + previewSetViewport, previewSnapshot, previewStatus, previewType, @@ -98,6 +99,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(previewScreenshot); yield* ipc.handle(previewOpenDevTools); yield* ipc.handle(previewSetColorScheme); + 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 26086585a..5819bac50 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -12,6 +12,7 @@ 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_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"; export const SET_THEME_CHANNEL = "desktop:set-theme"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 29d6eda2c..14c273a44 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -2,6 +2,7 @@ import { DesktopLocalServerSchema, DesktopPreviewClickInputSchema, DesktopPreviewColorSchemeInputSchema, + DesktopPreviewViewportInputSchema, DesktopPreviewEvaluateInputSchema, DesktopPreviewScreenshotSchema, DesktopPreviewSnapshotSchema, @@ -147,3 +148,16 @@ export const previewClearCache = makeIpcMethod({ yield* session.clearCache(); }), }); + +export const previewSetViewport = makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_VIEWPORT_CHANNEL, + payload: DesktopPreviewViewportInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setViewport")(function* (input) { + const automation = yield* PreviewAutomation.PreviewAutomation; + yield* automation.setViewport(input.webContentsId, { + width: input.width, + height: input.height, + }); + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 14a216023..e632ced0e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -122,6 +122,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, input), previewSetColorScheme: (input) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, input), + previewSetViewport: (input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_VIEWPORT_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 b6258f7dd..07bdbc74d 100644 --- a/apps/desktop/src/preview/PreviewAutomation.ts +++ b/apps/desktop/src/preview/PreviewAutomation.ts @@ -120,6 +120,10 @@ export class PreviewAutomation extends Context.Service< webContentsId: number, ) => Effect.Effect; readonly openDevTools: (webContentsId: number) => Effect.Effect; + readonly setViewport: ( + webContentsId: number, + size: { width: number | null; height: number | null }, + ) => Effect.Effect; readonly setColorScheme: ( webContentsId: number, colorScheme: "light" | "dark", @@ -377,6 +381,28 @@ export const make = Effect.gen(function* PreviewAutomationMake() { } return { ...buildStatus(webContentsId, contents), elements }; }), + setViewport: Effect.fn("PreviewAutomation.setViewport")(function* ( + webContentsId: number, + size: { width: number | null; height: number | null }, + ) { + const contents = yield* resolve(webContentsId); + // Resizing the element alone leaves the page believing it is still the + // old size: media queries do not re-evaluate and innerWidth is unchanged, + // so a narrow frame just clips a desktop layout instead of showing the + // mobile one. Overriding the metrics is what device mode actually is. + if (size.width === null || size.height === null) { + yield* sendCommand(contents, "Emulation.clearDeviceMetricsOverride", {}); + return; + } + yield* sendCommand(contents, "Emulation.setDeviceMetricsOverride", { + width: size.width, + height: size.height, + // Zero means "use the host's", which keeps text crisp on a retina + // display rather than rendering the page at 1x. + deviceScaleFactor: 0, + mobile: false, + }); + }), setColorScheme: Effect.fn("PreviewAutomation.setColorScheme")(function* ( webContentsId: number, colorScheme: "light" | "dark", diff --git a/apps/web/src/components/browser/BrowserPanel.tsx b/apps/web/src/components/browser/BrowserPanel.tsx index e6cc7729a..ef90c1263 100644 --- a/apps/web/src/components/browser/BrowserPanel.tsx +++ b/apps/web/src/components/browser/BrowserPanel.tsx @@ -145,6 +145,31 @@ export function BrowserPanel({ const activeWebview = () => webviewsRef.current.get(activeTabId) ?? null; + // Opening the device row on a responsive tab seeds concrete dimensions from + // whatever the page currently occupies. Without them there is nothing to drag + // and nothing to type over, so the row would open showing "auto" and do + // nothing until a preset was chosen. + const viewportAreaRef = useRef(null); + const measurePanelViewport = useCallback((): BrowserViewport | null => { + const area = viewportAreaRef.current; + if (area === null) { + return null; + } + const width = Math.max(160, Math.round(area.clientWidth - 24)); + const height = Math.max(160, Math.round(area.clientHeight - 24)); + return { width, height }; + }, []); + + useEffect(() => { + if (!deviceToolbarOpen || activeTab === null || activeTab.viewport.width !== null) { + return; + } + const measured = measurePanelViewport(); + if (measured !== null) { + setTabViewport(threadRef, activeTab.id, measured); + } + }, [activeTab, deviceToolbarOpen, measurePanelViewport, setTabViewport, threadRef]); + const submitAddress = useCallback( (event: React.FormEvent) => { event.preventDefault(); @@ -201,9 +226,113 @@ export function BrowserPanel({ - {/* Where the panel lives belongs with the tabs, not with the controls - that act on the page. */} + {/* Controls that act on the tab or the panel, kept out of the toolbar + so the address row stays free for controls that act on the page -- + annotate and element-pick will want that space. */}
+ + + + + + } + > + + + + { + const webview = webviewsRef.current.get(activeTabId); + callWhenReady(() => webview?.reloadIgnoringCache()); + }} + > + Hard reload + + + {deviceToolbarOpen ? "Hide device toolbar" : "Show device toolbar"} + + + setAppearance(value as BrowserAppearance)} + > + {/* The label belongs to the group: Base UI reads its context, and + outside one it throws and takes the whole menu with it. */} + Appearance + {(["system", "light", "dark"] as const).map((value) => ( + + {value === "system" ? "Follow app" : value === "light" ? "Light" : "Dark"} + + ))} + + + { + if (activeUrl !== null) { + void window.desktopBridge?.openExternal?.(activeUrl); + } + }} + > + Open in default browser + + { + if (activeUrl !== null) { + void navigator.clipboard.writeText(activeUrl).catch(() => {}); + } + }} + > + Copy address + + { + const webview = webviewsRef.current.get(activeTabId); + const id = + webview === undefined ? null : callWhenReady(() => webview.getWebContentsId()); + if (id !== null) { + void window.desktopBridge?.previewOpenDevTools?.({ webContentsId: id }); + } + }} + > + Open developer tools + + + { + void window.desktopBridge?.previewClearCache?.().then(() => { + callWhenReady(() => + webviewsRef.current.get(activeTabId)?.reloadIgnoringCache(), + ); + }); + }} + > + Clear cache + + { + // Signing out of the preview is the point, so reload after: the + // page on screen would otherwise still look signed in. + void window.desktopBridge?.previewClearBrowsingData?.().then(() => { + callWhenReady(() => webviewsRef.current.get(activeTabId)?.reload()); + }); + }} + > + Clear cookies and storage + + + )} + + +
@@ -247,113 +379,6 @@ export function BrowserPanel({ onChange={(event) => setAddressDraft(event.target.value)} /> - - - - - - - - } - > - - - - { - const webview = webviewsRef.current.get(activeTabId); - callWhenReady(() => webview?.reloadIgnoringCache()); - }} - > - Hard reload - - - {deviceToolbarOpen ? "Hide device toolbar" : "Show device toolbar"} - - - setAppearance(value as BrowserAppearance)} - > - {/* The label belongs to the group: Base UI reads its context, and - outside one it throws and takes the whole menu with it. */} - Appearance - {(["system", "light", "dark"] as const).map((value) => ( - - {value === "system" ? "Follow app" : value === "light" ? "Light" : "Dark"} - - ))} - - - { - if (activeUrl !== null) { - void window.desktopBridge?.openExternal?.(activeUrl); - } - }} - > - Open in default browser - - { - if (activeUrl !== null) { - void navigator.clipboard.writeText(activeUrl).catch(() => {}); - } - }} - > - Copy address - - { - const webview = webviewsRef.current.get(activeTabId); - const id = - webview === undefined ? null : callWhenReady(() => webview.getWebContentsId()); - if (id !== null) { - void window.desktopBridge?.previewOpenDevTools?.({ webContentsId: id }); - } - }} - > - Open developer tools - - - { - void window.desktopBridge?.previewClearCache?.().then(() => { - callWhenReady(() => webviewsRef.current.get(activeTabId)?.reloadIgnoringCache()); - }); - }} - > - Clear cache - - { - // Signing out of the preview is the point, so reload after: the - // page on screen would otherwise still look signed in. - void window.desktopBridge?.previewClearBrowsingData?.().then(() => { - callWhenReady(() => webviewsRef.current.get(activeTabId)?.reload()); - }); - }} - > - Clear cookies and storage - - - - - - -
{deviceToolbarOpen && activeTab !== null ? ( @@ -361,12 +386,18 @@ export function BrowserPanel({ viewport={activeTab.viewport} zoomFactor={activeTab.zoomFactor} onViewportChange={(viewport) => setTabViewport(threadRef, activeTab.id, viewport)} + onFitToPanel={() => { + const measured = measurePanelViewport(); + if (measured !== null) { + setTabViewport(threadRef, activeTab.id, measured); + } + }} onZoomChange={(factor) => setTabZoom(threadRef, activeTab.id, factor)} onClose={toggleDeviceToolbar} /> ) : null} -
+
{!isElectron ? ( ) : ( @@ -392,6 +423,11 @@ export function BrowserPanel({ viewport={tab.viewport} zoomFactor={tab.zoomFactor} colorScheme={guestColorScheme} + onResize={ + tab.id === activeTabId + ? (next) => setTabViewport(threadRef, tab.id, next) + : undefined + } onNavState={setNavState} register={(element) => { if (element === null) { @@ -470,6 +506,7 @@ function PreviewTabFrame({ viewport, zoomFactor, colorScheme, + onResize, onNavState, register, }: { @@ -479,6 +516,7 @@ function PreviewTabFrame({ viewport: BrowserViewport; zoomFactor: number; colorScheme: "light" | "dark"; + onResize?: ((viewport: BrowserViewport) => void) | undefined; onNavState: (state: NavState) => void; register: (element: PreviewWebview | null) => void; }) { @@ -489,6 +527,8 @@ function PreviewTabFrame({ isActiveRef.current = isActive; const colorSchemeRef = useRef(colorScheme); colorSchemeRef.current = colorScheme; + const zoomFactorRef = useRef(zoomFactor); + zoomFactorRef.current = zoomFactor; useEffect(() => { const webview = elementRef.current; @@ -509,6 +549,21 @@ function PreviewTabFrame({ callWhenReady(() => webview.setZoomFactor(zoomFactor)); }, [zoomFactor]); + useEffect(() => { + const webview = elementRef.current; + if (webview === null || !isElectron) { + return; + } + const id = callWhenReady(() => webview.getWebContentsId()); + if (id !== null) { + void window.desktopBridge?.previewSetViewport?.({ + webContentsId: id, + width: viewport.width, + height: viewport.height, + }); + } + }, [viewport.width, viewport.height]); + useEffect(() => { const webview = elementRef.current; if (webview === null || !isElectron) { @@ -517,6 +572,7 @@ function PreviewTabFrame({ let attachedId: number | null = null; const onAttached = () => { attachedId = webview.getWebContentsId(); + callWhenReady(() => webview.setZoomFactor(zoomFactorRef.current)); 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 @@ -539,6 +595,10 @@ function PreviewTabFrame({ } }; const onNavigated = () => { + // Electron remembers zoom per origin inside the session, so a page + // visited at 125% comes back at 125% while our control still reads + // 100%. Reasserting on every navigation keeps the two from drifting. + callWhenReady(() => webview.setZoomFactor(zoomFactorRef.current)); const url = callWhenReady(() => webview.getURL()); if (url !== null && url !== "") { setTabUrl(threadRef, tab.id, url); @@ -568,6 +628,8 @@ function PreviewTabFrame({ }; }, [onNavState, setTabTitle, setTabUrl, tab.id, threadRef]); + const framed = viewport.width !== null; + return (
- { - elementRef.current = element as PreviewWebview | null; - register(element as PreviewWebview | null); - }} - {...(isActive ? { "data-testid": "browser-panel-webview" } : {})} - className="h-full w-full bg-background" +
+ > + { + elementRef.current = element as PreviewWebview | null; + 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", + } + } + partition={PREVIEW_PARTITION} + {...(tab.url ? { src: tab.url } : {})} + /> + {framed && isActive && onResize !== undefined ? ( + <> + + + + + ) : null} +
); } +/** + * 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 (
-
+
-
+
-
+ +
); } 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 }) => ( ( strokeLinecap="round" strokeLinejoin="round" > - {/* The screen, caught on its corner mid-turn. */} - - {/* Its short edge: without this it is a diamond, not a screen. An arc and - arrowhead were tried alongside and only muddied it at 14px. */} - + {/* The device, upright, so the arrows read as turning it. */} + + {/* Turning down the left... */} + + + {/* ...and back up the right. */} + + ); 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({ -
+ setAddressDraft(event.target.value)} /> + {/* Inside the field, where it reads as "this address, elsewhere" + rather than as another page control. */} + + { + if (activeUrl !== null) { + void window.desktopBridge?.openExternal?.(activeUrl); + } + }} + /> + } + > + + + Open in default browser +
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({ +
+ ))} +
+ ); +} diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d04010017..fb23361ee 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -52,6 +52,10 @@ import { fileSelectionContextDedupKey, normalizeFileSelectionContextDraft, } from "./lib/fileSelectionContext"; +import { + normalizePickedElementContextDraft, + type PickedElementContextDraft, +} from "./lib/pickedElementContext"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; @@ -139,6 +143,21 @@ const PersistedTranscriptHighlightContextDraft = Schema.Struct({ type PersistedTranscriptHighlightContextDraft = typeof PersistedTranscriptHighlightContextDraft.Type; +const PersistedPickedElementContextDraft = Schema.Struct({ + id: Schema.String, + threadId: ThreadId, + createdAt: Schema.String, + tagName: Schema.String, + role: Schema.NullOr(Schema.String), + name: Schema.NullOr(Schema.String), + selector: Schema.String, + text: Schema.NullOr(Schema.String), + width: Schema.Number, + height: Schema.Number, + url: Schema.String, +}); +type PersistedPickedElementContextDraft = typeof PersistedPickedElementContextDraft.Type; + const PersistedFileSelectionContextDraft = Schema.Struct({ id: Schema.String, threadId: Schema.String, @@ -159,6 +178,7 @@ const PersistedComposerThreadDraftState = Schema.Struct({ Schema.Array(PersistedTranscriptHighlightContextDraft), ), fileSelectionContexts: Schema.optionalKey(Schema.Array(PersistedFileSelectionContextDraft)), + pickedElementContexts: Schema.optionalKey(Schema.Array(PersistedPickedElementContextDraft)), // Keyed by `ProviderInstanceId` (open branded slug) so custom provider // instances (e.g. `codex_personal`) round-trip alongside the built-in // `codex` / `claudeAgent` / ... entries. Every prior `ProviderDriverKind` @@ -281,6 +301,7 @@ export interface ComposerThreadDraftState { terminalContexts: TerminalContextDraft[]; transcriptHighlightContexts: TranscriptHighlightContextDraft[]; fileSelectionContexts: FileSelectionContextDraft[]; + pickedElementContexts: PickedElementContextDraft[]; /** * Per-instance model selection. Keyed by `ProviderInstanceId` (open * branded slug) so a default `codex` instance and a user-authored @@ -424,6 +445,10 @@ interface ComposerDraftStoreState { */ prefillEmptyPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void; setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; + setPickedElementContexts: ( + threadRef: ComposerThreadTarget, + contexts: PickedElementContextDraft[], + ) => void; setTranscriptHighlightContexts: ( threadRef: ComposerThreadTarget, contexts: TranscriptHighlightContextDraft[], @@ -568,6 +593,7 @@ const EMPTY_IDS: string[] = []; const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerAttachment[] = []; const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; const EMPTY_TRANSCRIPT_HIGHLIGHT_CONTEXTS: TranscriptHighlightContextDraft[] = []; +const EMPTY_PICKED_ELEMENT_CONTEXTS: PickedElementContextDraft[] = []; const EMPTY_FILE_SELECTION_CONTEXTS: FileSelectionContextDraft[] = []; Object.freeze(EMPTY_ATTACHMENTS); Object.freeze(EMPTY_IDS); @@ -589,6 +615,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ terminalContexts: EMPTY_TERMINAL_CONTEXTS, transcriptHighlightContexts: EMPTY_TRANSCRIPT_HIGHLIGHT_CONTEXTS, fileSelectionContexts: EMPTY_FILE_SELECTION_CONTEXTS, + pickedElementContexts: EMPTY_PICKED_ELEMENT_CONTEXTS, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, activeProvider: null, runtimeMode: null, @@ -604,6 +631,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { terminalContexts: [], transcriptHighlightContexts: [], fileSelectionContexts: [], + pickedElementContexts: [], modelSelectionByProvider: {}, activeProvider: null, runtimeMode: null, @@ -760,6 +788,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.terminalContexts.length === 0 && draft.transcriptHighlightContexts.length === 0 && draft.fileSelectionContexts.length === 0 && + draft.pickedElementContexts.length === 0 && Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && draft.runtimeMode === null && @@ -1882,6 +1911,7 @@ function partializeComposerDraftStoreState( draft.terminalContexts.length === 0 && draft.transcriptHighlightContexts.length === 0 && draft.fileSelectionContexts.length === 0 && + draft.pickedElementContexts.length === 0 && !hasModelData && draft.runtimeMode === null && draft.interactionMode === null @@ -1917,6 +1947,11 @@ function partializeComposerDraftStoreState( })), } : {}), + ...(draft.pickedElementContexts.length > 0 + ? { + pickedElementContexts: draft.pickedElementContexts.map((context) => ({ ...context })), + } + : {}), ...(draft.fileSelectionContexts.length > 0 ? { fileSelectionContexts: draft.fileSelectionContexts.map((context) => ({ @@ -2186,6 +2221,11 @@ function toHydratedThreadDraft( persistedDraft.transcriptHighlightContexts?.map((context) => ({ ...context })) ?? [], fileSelectionContexts: persistedDraft.fileSelectionContexts?.map((context) => ({ ...context })) ?? [], + pickedElementContexts: + persistedDraft.pickedElementContexts?.flatMap((context) => { + const normalized = normalizePickedElementContextDraft({ ...context }); + return normalized === null ? [] : [normalized]; + }) ?? [], modelSelectionByProvider, activeProvider, runtimeMode: persistedDraft.runtimeMode ?? null, @@ -2650,6 +2690,31 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + setPickedElementContexts: (threadRef, contexts) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId) { + return; + } + const normalizedContexts = contexts.flatMap((context) => { + const normalized = normalizePickedElementContextDraft({ ...context, threadId }); + return normalized === null ? [] : [normalized]; + }); + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const nextDraft: ComposerThreadDraftState = { + ...existing, + pickedElementContexts: normalizedContexts, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, setTranscriptHighlightContexts: (threadRef, contexts) => { const threadKey = resolveComposerDraftKey(get(), threadRef); const threadId = resolveComposerThreadId(get(), threadRef); @@ -3454,6 +3519,7 @@ const composerDraftStore = create()( terminalContexts: [], transcriptHighlightContexts: [], fileSelectionContexts: [], + pickedElementContexts: [], }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { diff --git a/apps/web/src/lib/pickedElementContext.test.ts b/apps/web/src/lib/pickedElementContext.test.ts index bfd6fbc74..600412a40 100644 --- a/apps/web/src/lib/pickedElementContext.test.ts +++ b/apps/web/src/lib/pickedElementContext.test.ts @@ -1,46 +1,96 @@ +import { ThreadId } from "@threadlines/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { describePickedElementForComposer } from "./pickedElementContext"; +import { + appendPickedElementContextsToPrompt, + formatPickedElementContextLabel, + normalizePickedElementContextDraft, + pickedElementFromPreview, + type PickedElementContext, +} from "./pickedElementContext"; -const base = { +const context: PickedElementContext = { tagName: "button", role: "button", name: "Sign in", selector: "#cta", text: "Sign in", - rect: { x: 10, y: 20, width: 220, height: 60 }, + width: 220, + height: 60, url: "http://localhost:5173/", }; -describe("describePickedElementForComposer", () => { - 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/', - ); +const draft = { + ...context, + id: "pick-1", + threadId: ThreadId.make("thread-1"), + createdAt: "2026-07-26T00:00:00.000Z", +}; + +describe("formatPickedElementContextLabel", () => { + it("leads with role and name, which is how you would describe it aloud", () => { + expect(formatPickedElementContextLabel(context)).toBe('button "Sign in"'); + }); + + it("falls back to the selector when the element has no accessible name", () => { + expect(formatPickedElementContextLabel({ ...context, role: null, name: null })).toBe("#cta"); + }); +}); + +describe("normalizePickedElementContextDraft", () => { + it("drops a pick with no selector, since nothing could act on it", () => { + expect(normalizePickedElementContextDraft({ ...draft, selector: " " })).toBeNull(); + }); + + it("treats blank role and name as absent rather than empty", () => { + const normalized = normalizePickedElementContextDraft({ ...draft, role: " ", name: " " }); + + expect(normalized).toMatchObject({ role: null, name: null }); + }); +}); + +describe("appendPickedElementContextsToPrompt", () => { + it("appends a block the agent can read without disturbing the message", () => { + const prompt = appendPickedElementContextsToPrompt("this button is misaligned", [context]); + + expect(prompt).toContain("this button is misaligned"); + expect(prompt).toContain(""); + expect(prompt).toContain("selector: #cta"); + expect(prompt).toContain("role: button"); }); it("omits text that only repeats the name", () => { - expect(describePickedElementForComposer(base)).not.toContain("text:"); + expect(appendPickedElementContextsToPrompt("", [context])).not.toContain("text:"); }); - it("includes text when it says something the name does not", () => { - const described = describePickedElementForComposer({ - ...base, - name: "Submit", - text: "Submit your application", - }); + it("collapses repeat picks of the same element on the same page", () => { + const prompt = appendPickedElementContextsToPrompt("", [context, { ...context }]); - expect(described).toContain('text: "Submit your application"'); + expect(prompt.match(//g)).toHaveLength(1); }); - it("falls back to the tag when the element has no accessible name", () => { - const described = describePickedElementForComposer({ - ...base, - role: null, - name: null, - text: null, - }); + it("keeps the same selector on a different page as its own context", () => { + const prompt = appendPickedElementContextsToPrompt("", [ + context, + { ...context, url: "http://localhost:5173/settings" }, + ]); + + expect(prompt.match(//g)).toHaveLength(2); + }); +}); - expect(described).toContain("
+ ); +} + +function PickedElementChip({ + context, + onRemove, + onUpdateNote, + onReveal, +}: { + context: PickedElementContextDraft; + onRemove: (contextId: string) => void; + onUpdateNote: (contextId: string, note: string) => void; + onReveal?: ((context: PickedElementContextDraft) => void) | undefined; +}) { + const noteInputRef = useRef(null); + const [open, setOpen] = useState(false); + const [noteDraft, setNoteDraft] = useState(context.note ?? ""); + const descriptor = formatPickedElementDescriptor(context); + + // Follow the stored note when it changes elsewhere. + useEffect(() => { + setNoteDraft(context.note ?? ""); + }, [context.note]); + + useEffect(() => { + if (!open) { + return; + } + const frameId = window.requestAnimationFrame(() => { + const input = noteInputRef.current; + if (input === null) { + return; + } + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + }); + return () => window.cancelAnimationFrame(frameId); + }, [open]); + + const trimmed = noteDraft.trim(); + const changed = trimmed !== (context.note ?? "").trim(); + + const save = () => { + if (changed) { + onUpdateNote(context.id, trimmed); + } + setOpen(false); + }; + + return ( + { + if (next) { + setNoteDraft(context.note ?? ""); + } + setOpen(next); + }} + > + + - {formatPickedElementContextLabel(context)} - - {/* The label is the short form; the tooltip carries what a truncated - chip had to drop, which is what identifies it on the page. */} - - {onReveal === undefined ? null : ( - Click to show in preview + {descriptor} + {/* A dot rather than the note itself: the chip stays one line, + and this only needs to say there is something behind it. */} + {context.note === null ? null : ( + )} - {context.selector} - - {context.width}×{context.height} · {context.url} - - - - + + + +

{descriptor}

+

+ {context.selector} +

+