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 ( -
-
-