From 91bf927aae5981a27729833e6fb6112e6aad9434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Tue, 30 Jun 2026 08:32:52 +0000 Subject: [PATCH] prototype: title-led session header exploration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained, unauthed prototype at /prototype/session-header exploring a minimal collapsed session header where the expand/collapse dropdown becomes the canonical details surface (full title + initial prompt + all ports). - Three header variations (A minimal, B two-line, C scroll-strip) - Stress-test mock data: 50 ports, huge titles, long lineage/branch names - Live overflow scanner highlights unintended horizontal overflow in red - Mobile-first (375px) + desktop fluid side-by-side preview Prototype only — removed before any merge to main per the prototype skill. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/App.tsx | 5 + .../pages/session-header-prototype/index.tsx | 568 ++++++++++++++++++ .../session-header-prototype/mock-data.ts | 141 +++++ 3 files changed, 714 insertions(+) create mode 100644 apps/web/src/pages/session-header-prototype/index.tsx create mode 100644 apps/web/src/pages/session-header-prototype/mock-data.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 66e6475fa..b1c0e9d31 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -48,6 +48,7 @@ import { ProjectSkills } from './pages/ProjectSkills'; import { ProjectTriggerDetail } from './pages/ProjectTriggerDetail'; import { ProjectTriggers } from './pages/ProjectTriggers'; import { SamPrototype } from './pages/SamPrototype'; +import { SessionHeaderPrototype } from './pages/session-header-prototype'; import { Settings } from './pages/Settings'; import { SettingsAgents } from './pages/SettingsAgents'; import { SettingsApiTokens } from './pages/SettingsApiTokens'; @@ -97,6 +98,10 @@ export default function App() { } /> {/* SAM prototype — public, no auth */} } /> + } + /> } /> {/* Harness for Playwright audits — mounts trial components with mock data */} } /> diff --git a/apps/web/src/pages/session-header-prototype/index.tsx b/apps/web/src/pages/session-header-prototype/index.tsx new file mode 100644 index 000000000..f6da0c53b --- /dev/null +++ b/apps/web/src/pages/session-header-prototype/index.tsx @@ -0,0 +1,568 @@ +import { + Bot, + Box, + ChevronDown, + ChevronUp, + Cloud, + Cpu, + ExternalLink, + Globe, + Hash, + MessageSquare, + Server, + Tag, +} from 'lucide-react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { type MockPort, type MockScenario, SCENARIOS } from './mock-data'; + +// --------------------------------------------------------------------------- +// Prototype: title-led SessionHeader +// +// Self-contained, no API calls, no auth. Explores making the collapsed header +// row minimal (title-led) and turning the expand/collapse dropdown into the +// canonical details surface (full title + initial prompt + all ports). +// +// Three variations are rendered side-by-side so we can compare under comically +// large stress-test data (50 ports, unbreakable mega-titles, etc.). +// --------------------------------------------------------------------------- + +type Variation = 'A' | 'B' | 'C'; + +const VARIATION_LABELS: Record = { + A: 'A · Title-led, minimal row', + B: 'B · Two-line header', + C: 'C · Horizontal-scroll badge strip', +}; + +const VARIATION_BLURB: Record = { + A: 'Collapsed row = title + status + chevron only. Everything else (badges, ports, full title, initial prompt) lives in the dropdown.', + B: 'Title gets its own line (clamped to 2 lines). Row 2 holds status + first port + “+N”. Dropdown is the canonical details surface.', + C: 'Title truncates; secondary chips live in a horizontally scrollable strip (intentional horizontal scroll). Dropdown still holds everything.', +}; + +function statusColor(status: MockScenario['status']): string { + if (status === 'active') return 'var(--sam-color-success)'; + if (status === 'idle') return 'var(--sam-color-warning)'; + return 'var(--sam-color-fg-muted)'; +} + +function statusLabel(status: MockScenario['status']): string { + if (status === 'active') return 'Active'; + if (status === 'idle') return 'Idle'; + return 'Stopped'; +} + +function profileBadgeColors(profile: MockScenario['profile']): { bg: string; fg: string } { + if (profile === 'lightweight') return { bg: 'var(--sam-color-info-tint)', fg: 'var(--sam-color-info)' }; + if (profile === 'recovery') return { bg: 'var(--sam-color-warning-tint)', fg: 'var(--sam-color-warning)' }; + return { bg: 'var(--sam-color-success-tint)', fg: 'var(--sam-color-success)' }; +} + +function profileLabel(profile: MockScenario['profile']): string { + if (profile === 'lightweight') return 'Lightweight'; + if (profile === 'recovery') return 'Recovery container'; + return 'Full'; +} + +// ---- Shared sub-components -------------------------------------------------- + +function StatusPill({ status }: { status: MockScenario['status'] }) { + return ( + + + {statusLabel(status)} + + ); +} + +function ProfileBadge({ profile }: { profile: MockScenario['profile'] }) { + const c = profileBadgeColors(profile); + return ( + + {profileLabel(profile)} + + ); +} + +function PortPill({ port }: { port: MockPort }) { + return ( + + + {port.port} + + ); +} + +/** The canonical "details" surface shared by every variation. */ +function ExpandedDetails({ scenario }: { scenario: MockScenario }) { + const sortedPorts = scenario.ports.slice().sort((a, b) => a.port - b.port); + return ( +
+ {/* FULL TITLE — the thing the friend went looking for. Wraps freely. */} +
+
+ + Title +
+
+ {scenario.title} +
+
+ + {/* INITIAL PROMPT — already in memory as the first user message. */} +
+
+ + Initial prompt +
+
+ {scenario.initialPrompt} +
+
+ + {/* Lineage + profile + agent meta */} +
+ + {scenario.lineageText && ( + + {scenario.lineageText} + + )} + + + {scenario.agentType} + + + {scenario.taskMode === 'conversation' ? : } + {scenario.taskMode === 'conversation' ? 'Conversation' : 'Task'} + + {scenario.agentProfileHint && ( + + {scenario.agentProfileHint} + + )} +
+ + {/* Infra */} +
+ } label="Workspace" value={scenario.workspaceName} /> + } label="VM Size" value={scenario.vmSize} /> + } label="Node" value={scenario.nodeName} /> + } label="Provider" value={scenario.provider} /> + {scenario.branch && } label="Branch" value={scenario.branch} mono />} +
+ + {/* ALL ports — full list, wraps. This is where "+N" / the strip lands you. */} + {sortedPorts.length > 0 && ( +
+
+ + Ports ({sortedPorts.length}) +
+ +
+ )} +
+ ); +} + +function InfraRow({ icon, label, value, mono }: { icon: React.ReactNode; label: string; value: string; mono?: boolean }) { + return ( +
+ {icon} + {label}: + + {value} + +
+ ); +} + +// ---- The three header variations ------------------------------------------ + +function HeaderShell({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function VariationA({ scenario, expanded, onToggle }: { scenario: MockScenario; expanded: boolean; onToggle: () => void }) { + return ( + +
+ + {scenario.title} + + + +
+ {expanded && } +
+ ); +} + +function VariationB({ scenario, expanded, onToggle }: { scenario: MockScenario; expanded: boolean; onToggle: () => void }) { + const sortedPorts = scenario.ports.slice().sort((a, b) => a.port - b.port); + const firstPort = sortedPorts[0]; + const extra = sortedPorts.length - 1; + return ( + +
+ {/* Row 1: title gets its own line, clamped to 2 lines */} +
+ + {scenario.title} + + +
+ {/* Row 2: status + first port + +N (tap chevron / row to see all in dropdown) */} +
+ + + {firstPort && } + {extra > 0 && ( + + )} +
+
+ {expanded && } +
+ ); +} + +function VariationC({ scenario, expanded, onToggle }: { scenario: MockScenario; expanded: boolean; onToggle: () => void }) { + const sortedPorts = scenario.ports.slice().sort((a, b) => a.port - b.port); + return ( + +
+ + {scenario.title} + + + +
+ {/* Intentionally horizontally scrollable secondary strip. */} + {(scenario.ports.length > 0 || scenario.lineageText) && ( +
+ + {scenario.lineageText && ( + + {scenario.lineageText.startsWith('⑂') ? '⑂ fork' : scenario.lineageText} + + )} + {sortedPorts.map((p) => )} +
+ )} + {expanded && } +
+ ); +} + +// ---- Overflow scanner ------------------------------------------------------ + +/** + * Walks the prototype root and outlines (in red) any element whose content + * overflows horizontally and is NOT explicitly marked data-scrollable. Also + * reports a count so we can see at a glance whether a layout is clean. + */ +function useOverflowScan(rootRef: React.RefObject, deps: unknown[], enabled: boolean) { + const [count, setCount] = useState(0); + useLayoutEffect(() => { + const root = rootRef.current; + if (!root) return; + const all = root.querySelectorAll('*'); + let bad = 0; + all.forEach((el) => { + el.style.outline = ''; + el.removeAttribute('data-overflowing'); + if (!enabled) return; + // Skip elements (and descendants of elements) that are meant to scroll. + if (el.closest('[data-scrollable="true"]')) return; + const overflowsX = el.scrollWidth - el.clientWidth > 1; + if (!overflowsX) return; + // Only a *visible* overflow is a real problem. truncate (overflow:hidden / + // text-overflow:ellipsis) and auto/scroll containers clip or scroll their + // content correctly — their scrollWidth > clientWidth is by design, not a bug. + const ox = getComputedStyle(el).overflowX; + if (ox === 'hidden' || ox === 'clip' || ox === 'auto' || ox === 'scroll') return; + el.style.outline = '2px solid #ef4444'; + el.style.outlineOffset = '-1px'; + el.setAttribute('data-overflowing', 'true'); + bad += 1; + }); + setCount(bad); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, ...deps]); + return count; +} + +// ---- Phone / desktop frame ------------------------------------------------- + +function DeviceFrame({ width, label, children }: { width: number | '100%'; label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
+ {children} +
+
+ ); +} + +// ---- Main page ------------------------------------------------------------- + +export function SessionHeaderPrototype() { + const [scenarioId, setScenarioId] = useState(SCENARIOS[0].id); + const [variation, setVariation] = useState('A'); + const [expanded, setExpanded] = useState(true); + const [highlight, setHighlight] = useState(true); + + const scenario = SCENARIOS.find((s) => s.id === scenarioId) ?? SCENARIOS[0]; + + const mobileRef = useRef(null); + const desktopRef = useRef(null); + + const Header = + variation === 'A' ? VariationA : variation === 'B' ? VariationB : VariationC; + + const mobileOverflows = useOverflowScan(mobileRef, [scenarioId, variation, expanded], highlight); + const desktopOverflows = useOverflowScan(desktopRef, [scenarioId, variation, expanded], highlight); + + // Re-scan on window resize too. + const [, forceTick] = useState(0); + useEffect(() => { + const onResize = () => forceTick((t) => t + 1); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + return ( +
+
+
+

+ Title-led SessionHeader — prototype +

+

+ Red outlines = unintended horizontal overflow. Strips marked “scroll” are allowed to overflow. +

+
+ + {/* Controls */} +
+ + + + +
+ {(['A', 'B', 'C'] as Variation[]).map((v) => ( + + ))} +
+
+ + + + + + +
+ +

+ {VARIATION_LABELS[variation]} — {VARIATION_BLURB[variation]} +

+ + {/* Overflow report */} +
+ + mobile overflows: {mobileOverflows} + + + desktop overflows: {desktopOverflows} + +
+ + {/* Frames */} +
+
+ +
setExpanded((v) => !v)} /> + +
+
+ +
setExpanded((v) => !v)} /> + +
+
+
+
+ ); +} + +function Control({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +export default SessionHeaderPrototype; diff --git a/apps/web/src/pages/session-header-prototype/mock-data.ts b/apps/web/src/pages/session-header-prototype/mock-data.ts new file mode 100644 index 000000000..f2357e12f --- /dev/null +++ b/apps/web/src/pages/session-header-prototype/mock-data.ts @@ -0,0 +1,141 @@ +// Stress-test mock data for the title-led SessionHeader prototype. +// Intentionally comical amounts of data to find where the layout breaks. + +export interface MockPort { + port: number; + address: string; + label: string; + url: string; +} + +export interface MockScenario { + id: string; + /** Short human label for the scenario picker. */ + name: string; + title: string; + /** Initial prompt that started the session (first user message). */ + initialPrompt: string; + lineageText?: string; + profile: 'lightweight' | 'full' | 'recovery'; + status: 'active' | 'idle' | 'stopped'; + ports: MockPort[]; + workspaceName: string; + vmSize: string; + nodeName: string; + provider: string; + branch?: string; + agentType: string; + taskMode: 'task' | 'conversation'; + agentProfileHint?: string; +} + +function makePorts(count: number): MockPort[] { + const labels = [ + 'Vite Dev', 'Django', 'Next.js', 'Storybook', 'Postgres', 'Redis', 'Mailhog', + 'API', 'Worker', 'Webpack', 'Jest', 'Playwright', 'Grafana', 'Prometheus', + 'Jupyter', 'Flask', 'Rails', 'Sidekiq', 'Adminer', 'pgweb', + ]; + return Array.from({ length: count }, (_, i) => { + const port = 3000 + i; + return { + port, + address: i % 4 === 0 ? '127.0.0.1' : '0.0.0.0', + label: labels[i % labels.length], + url: `https://ws-abc123def456--${port}.sammy.party`, + }; + }); +} + +const LONG_PROMPT = `I want you to refactor the entire authentication subsystem so that we move away from the legacy cookie-based session middleware and adopt a token-rotation model with per-project credential overrides. While you're in there, please also audit every callback route to make sure VM agent callbacks aren't leaking into the session-auth wildcard, add regression tests for the inactive-scoped-row-blocks-fallback invariant, update the self-hosting docs, and make sure the staging deploy is green before you even think about merging. Oh and the friend who was testing yesterday said the header title was unreadable on his phone, so fix that too. Thanks!`; + +export const SCENARIOS: MockScenario[] = [ + { + id: 'extreme', + name: '50 ports + huge title', + title: + 'Refactor the entire authentication subsystem to use token rotation with per-project credential overrides and fix the unreadable mobile header title while you are at it', + initialPrompt: LONG_PROMPT, + lineageText: '⑂ forked from "Investigate flaky credential resolution tests on staging"', + profile: 'lightweight', + status: 'active', + ports: makePorts(50), + workspaceName: 'auth-subsystem-refactor-with-a-very-long-workspace-display-name', + vmSize: 'cpx41', + nodeName: 'node-hetzner-fsn1-pool-warm-7f3a9c2b1e', + provider: 'hetzner', + branch: 'feature/token-rotation-per-project-credential-overrides-and-callback-audit', + agentType: 'claude-code', + taskMode: 'task', + agentProfileHint: 'Senior Backend Engineer (Cloudflare + Auth specialist profile)', + }, + { + id: 'unbreakable', + name: 'No-space mega word', + title: + 'Supercalifragilisticexpialidocious_refactor_the_authentication_subsystem_with_no_spaces_at_all_to_test_truncation_versus_wrapping_behavior_in_the_header', + initialPrompt: + 'Fix this:\npnpm_quality_migration_safety_check_is_failing_because_of_a_very_long_unbreakable_identifier_that_will_not_wrap', + profile: 'recovery', + status: 'idle', + ports: makePorts(7), + workspaceName: 'recovery-container-fallback', + vmSize: 'cpx21', + nodeName: 'node-scaleway-par1-0a1b2c3d', + provider: 'scaleway', + branch: 'fix/migration-safety-very-long-branch-name-that-keeps-going-and-going', + agentType: 'openai-codex', + taskMode: 'conversation', + agentProfileHint: 'Default', + }, + { + id: 'typical', + name: 'Typical session', + title: 'Add dark mode toggle to settings page', + initialPrompt: 'Add a dark mode toggle to the application settings page. Make sure it persists across reloads.', + profile: 'full', + status: 'active', + ports: makePorts(2), + workspaceName: 'dark-mode-toggle', + vmSize: 'cpx31', + nodeName: 'node-hetzner-nbg1-warm', + provider: 'hetzner', + branch: 'feature/dark-mode-toggle', + agentType: 'claude-code', + taskMode: 'task', + agentProfileHint: 'Frontend Engineer', + }, + { + id: 'minimal', + name: 'Minimal / short', + title: 'Hi', + initialPrompt: 'Hi', + profile: 'lightweight', + status: 'stopped', + ports: [], + workspaceName: 'ws-x', + vmSize: 'cpx11', + nodeName: 'node-a', + provider: 'hetzner', + agentType: 'claude-code', + taskMode: 'conversation', + }, + { + id: 'medium-ports', + name: '12 ports + medium title', + title: 'Wire up the new observability dashboard with live AI Gateway usage metrics', + initialPrompt: + 'Build the per-user LLM usage dashboard. Pull data from AI Gateway logs, break it down by model and by day, and add a budget-remaining indicator.', + lineageText: '↩ attempt 3', + profile: 'full', + status: 'active', + ports: makePorts(12), + workspaceName: 'observability-dashboard', + vmSize: 'cpx41', + nodeName: 'node-hetzner-fsn1-dedicated', + provider: 'hetzner', + branch: 'feature/ai-usage-dashboard', + agentType: 'claude-code', + taskMode: 'task', + agentProfileHint: 'Full-stack Engineer', + }, +];