From 4f699bb90e0a7bf8d95a341fff9a529e5dc34713 Mon Sep 17 00:00:00 2001 From: jules Date: Sat, 2 May 2026 19:32:22 +1000 Subject: [PATCH] ai: redesign /ai surface as Mission Console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the conventional chat aesthetic on /ai with a brutalist-mono operator deck. The page now reads as a flight recorder — turn numbers in the gutter, hairline rules, sodium-amber phosphor primary on deep-ink ground, vim-style modeline at the foot. Type system is the design's load-bearing element: - JetBrains Mono for everything system-y (operator lines, signatures, modeline, session ids, tool calls) - Newsreader serif for the agent's prose only — the synthesis voice literally lifts off the page in a different family from the machine voice. Operator and agent are typographically inseparable from their speaker. Layout changes: - Sticky session header with a giant base36 session id ("3K9P · A4C2") and a metadata strip showing agent, model, turn count, status. The status pill flips colour: AMBER on stream, ROSE on awaiting confirm, MINT on ready, MUTED on mock. - Empty state is no longer the apologetic "How can I help you today?". It's "ATLAS. standing by." in oversize mono with the agent name in italic serif amber, a hairline divider, and a single one-liner instruction prefixed with ›. Lines stagger in via animation-delay. - Operator turns: monospace, 14px, sodium-amber › prompt, no bubble. Hangs from a left gutter with T01/T02… turn number + UTC timestamp. - Agent turns: serif, 17px/1.55, with a tiny mono signature underneath ("atlas» 03:14:08Z · recv"). Cyan accent column instead of amber. - Composer: terminal frame (square, 1px border, focus ring is amber glow). Internal ›_ prompt mark in front of the textarea, mono input. - Bottom modeline: utc clock + turn count + estimated tokens on the left, keyboard hints on the right. Streaming flips the right side to a pulsing phosphor bar + STREAM label. Atmosphere details: - 2px scanline overlay (very faint, 1.2% opacity) - Corner phosphor blooms (amber top-right, cyan bottom-left) - Inline SVG turbulence grain (3.5% opacity) over the whole theme - Cursor blink animation on the prompt mark - Consolas-tier ligatures on the mono via JetBrains Mono ss01/calt All theming scoped via [data-theme="console"] — picks up automatically because /ai's AppShell now passes theme="console". Other routes are untouched. Tool-call cards from @crema/agent-ui inherit the palette via overridden CSS variables (--card, --border, --primary, etc) plus a [data-slot="tool-call-card"] override for the frame. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app.css | 4 + app/routes/ai.tsx | 281 +++++++++++++++++++++++++++----- app/themes/console.css | 355 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 598 insertions(+), 42 deletions(-) create mode 100644 app/themes/console.css diff --git a/app/app.css b/app/app.css index 5391ae3..26b15b7 100644 --- a/app/app.css +++ b/app/app.css @@ -3,6 +3,10 @@ /* Active theme — must be first so its @import url() font directives resolve * to the top of the output. Themes are self-contained: tokens + fonts. */ @import "../../lib-theme-skyrise/theme.css"; /* CREMA:THEME */ + +/* Per-route alt theme — applied via [data-theme="console"] on AppShell. */ +@import "./themes/console.css"; + @import "tailwindcss"; @import "tw-animate-css"; @import "shadcn/tailwind.css"; diff --git a/app/routes/ai.tsx b/app/routes/ai.tsx index 127f165..3d66c42 100644 --- a/app/routes/ai.tsx +++ b/app/routes/ai.tsx @@ -316,7 +316,7 @@ export default function AIRoute() { const availableModels = status.kind === "live" ? status.models : ["mock"] return ( - + !!loadAISnapshot(), ) + // Session label — stable for the duration of the page load. Encoded in + // base36 from the mount timestamp; just a unique-feeling moniker for + // the operator's eye, not anything semantic. + const [sessionLabel] = useState(() => + typeof window === "undefined" + ? "0000-0000" + : `${Math.floor(Date.now() / 1000).toString(36).slice(-4).toUpperCase()}-${Math.random() + .toString(36) + .slice(2, 6) + .toUpperCase()}`, + ) + + // Live clock for the modeline / signatures, ticking every second. + const [now, setNow] = useState(() => new Date()) + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 1000) + return () => clearInterval(id) + }, []) + const clockLabel = now + .toISOString() + .slice(11, 19) /* HH:MM:SS in UTC */ + + "Z" + const hasAssistantReply = messages.some((m) => m.role === "assistant") const buildTranscript = useCallback(() => { @@ -638,20 +661,82 @@ function ChatSurface({ const isEmpty = messages.length === 0 + // Token estimate for the modeline. Cheap heuristic, adequate for + // operator-glance display. + const estTokensTotal = messages.reduce( + (n, m) => n + Math.ceil((m.content?.length ?? 0) / 4), + 0, + ) + const userTurns = messages.filter((m) => m.role === "user").length + return (
- {/* Greeting — only when empty, fades out as composer slides down */} + {/* Session header — flight-recorder strip. Hidden in the empty state + * because the empty state already shows session metadata. */} + {!isEmpty && ( +
+
+
+ session + + {sessionLabel.split("-")[0]} + · + {sessionLabel.split("-")[1]} + +
+
+ + + + +
+
+
+ )} + + {/* Empty state — flight-recorder card with staggered reveal */}
-

- How can I help you today? -

+
+
+ arcadia // operator console + session {sessionLabel} +
+
+

+ ATLAS. +
+ standing by +

+

+ {" "} + Issue an instruction. Read tools run automatically. Writes pause for + confirmation. Tab ⇥ for command palette. +

+
{/* Messages — rendered when there are any. In empty state a flex-grow @@ -679,6 +764,9 @@ function ChatSurface({ role={m.role as "user" | "assistant"} content={m.content} toolCalls={m.toolCalls} + turnNum={i + 1} + agentName={activeAgent?.name ?? "Atlas"} + timestamp={clockLabel} /> {calls.length > 0 && (
@@ -785,6 +873,47 @@ function ChatSurface({ )}
+ + {/* Modeline — vim-style status strip. Pinned above the AppShell's own + * footer/padding so it always reads in the operator's bottom band. */} +
+
+
+ + utc + {clockLabel} + + + turn + + {userTurns.toString().padStart(2, "0")} + + + + tok + ~{estTokensTotal.toLocaleString()} + +
+
+ {isStreaming ? ( + + + + STREAM + + + ) : ( + + enter + send + + enter + newline + + )} +
+
+
) } @@ -856,36 +985,104 @@ function buildAgentToolCall( } } +function SessionMeta({ + label, + value, + tone = "default", +}: { + label: string + value: string + tone?: "default" | "amber" | "rose" | "mint" | "muted" +}) { + const toneColor = { + default: "var(--console-text)", + amber: "var(--console-amber)", + rose: "var(--console-rose)", + mint: "var(--console-mint)", + muted: "var(--console-muted)", + }[tone] + return ( +
+ {label} + + {value} + +
+ ) +} + +function truncateModel(m: string): string { + if (!m) return "—" + if (m.length <= 22) return m + return m.slice(0, 10) + "…" + m.slice(-9) +} + function MessageRow({ role, content, toolCalls, + turnNum, + agentName, + timestamp, }: { role: "user" | "assistant" content: string toolCalls?: ToolCall[] + turnNum?: number + agentName?: string + timestamp?: string }) { + // Operator turn — monospace, sodium-amber prompt, no bubble. The whole + // row hangs from a left gutter showing the turn number. if (role === "user") { return ( -
-
-
{content}
+
+
+ + T{(turnNum ?? 0).toString().padStart(2, "0")} + + {timestamp ? ( + + {timestamp} + + ) : null} +
+
+
+ ›  + {content} +
) } - // Assistant messages with only tool_calls and no prose render nothing here — - // the ToolCallCards beneath them carry the visual weight. + + // Assistant turn — set in serif, with a tiny mono signature beneath. If + // there's no prose (just tool calls), suppress the row entirely. if (!content.trim()) return null return ( -
- +
+
+ + T{(turnNum ?? 0).toString().padStart(2, "0")} + + + {agentName?.slice(0, 6).toLowerCase() ?? "atlas"} + +
+
+
+ +
+
+ + {agentName?.toLowerCase() ?? "atlas"}» + + {timestamp ? {timestamp} : null} + · + recv +
+
) } @@ -965,26 +1162,26 @@ function Composer({ } return ( -
-
-