diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 089fb3a..1025e69 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -7,7 +7,7 @@ import { } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it } from "vitest"; -import App from "./App"; +import App, { ErrorBoundary } from "./App"; import { clearSettings } from "./settings"; import { FakeWebSocket, @@ -114,7 +114,7 @@ describe("App shell", () => { const sock = FakeWebSocket.last(); act(() => sock.serverOpen()); expect( - screen.getByRole("img", { name: "connection open" }), + screen.getByTitle("ws open"), ).toBeInTheDocument(); const sidebar = screen.getByLabelText("Sessions"); @@ -223,3 +223,43 @@ describe("App shell", () => { function container_backdrop(): HTMLElement | null { return document.querySelector(".sidebar-backdrop"); } + +describe("ErrorBoundary direct", () => { + it("catches child render error and offers reload", () => { + function Bomb(): React.ReactNode { + throw new Error("kaboom-ui"); + } + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + function Harness(): React.ReactNode { + return ( + + + + + + ); + } + render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/kaboom-ui/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument(); + consoleSpy.mockRestore(); + }); +}); + +describe("conn banner + sidebar sections", () => { + it("reconnecting banner shows before ws opens; Active/Archive sections render", async () => { + seedApi(); // fixture sessions: s1 online, s2/s3 offline + seedSettings(); + renderApp("/"); + + // before the fake socket opens: banner visible + await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); + expect(screen.getByText(/reconnecting/i)).toBeInTheDocument(); + expect(screen.getByText("Active")).toBeInTheDocument(); + expect(screen.getByText("Archive")).toBeInTheDocument(); + + act(() => FakeWebSocket.last().serverOpen()); + expect(screen.queryByText(/reconnecting/i)).toBeNull(); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index e8cc9c8..a0deb1e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { Component, useEffect, useState, type ReactNode } from "react"; import { NavLink, Navigate, @@ -12,6 +12,74 @@ import SpawnView from "./SpawnView"; import SettingsGate from "./SettingsGate"; import { classNames, useSessions, useToasts } from "./store"; import { clearSettings, getSettings } from "./settings"; +import type { SessionListItem } from "./protocol"; + +interface BoundaryState { + error: Error | null; +} + +export class ErrorBoundary extends Component<{ children: ReactNode }, BoundaryState> { + state: BoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): BoundaryState { + return { error }; + } + + render(): ReactNode { + if (this.state.error === null) return this.props.children; + return ( +
+

Something broke

+
{String(this.state.error?.stack ?? this.state.error)}
+ +
+ ); + } +} + +function SidebarSection({ + label, + sessions, + emptyText, +}: { + label: string; + sessions: SessionListItem[]; + emptyText: string; +}): ReactNode { + return ( + <> +
{label}
+ {sessions.length === 0 && ( +
{emptyText}
+ )} + {sessions.map((s) => ( + + classNames( + "session-link", + isActive && "active", + !s.online && "archived", + ) + } + > + + {s.name ?? s.repo ?? s.id} + + ))} + + ); +} export default function App() { const [configured, setConfigured] = useState(getSettings() !== null); @@ -28,6 +96,11 @@ export default function App() { if (store === null) return setConfigured(true)} />; + const byActivity = (a: SessionListItem, b: SessionListItem): number => + (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt); + const active = store.sessions.filter((s) => s.online).sort(byActivity); + const archived = store.sessions.filter((s) => !s.online).sort(byActivity); + return (
- + + + {store.state} +
- {[...store.sessions] - .filter((s) => s.online) - .sort( - (a, b) => - (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt), - ) - .map((s) => ( - - classNames("session-link", isActive && "active") - } - style={{ display: "flex", alignItems: "center", gap: 6 }} - > - - - {s.name ?? s.repo ?? s.id} - - - ))} + +
@@ -104,6 +161,12 @@ export default function App() { /> )}
+ {store.state !== "open" && ( +
+ + connection {store.state} — reconnecting… +
+ )} - - void store.refresh()} - pushToast={push} - /> - } - /> - } - /> - } - /> - } /> - + + + void store.refresh()} + pushToast={push} + /> + } + /> + } + /> + } + /> + } /> + +
{toasts.map((t) => ( diff --git a/web/src/ChatStream.test.tsx b/web/src/ChatStream.test.tsx index d223058..d239135 100644 --- a/web/src/ChatStream.test.tsx +++ b/web/src/ChatStream.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import type { ChatMessage, ToolState } from "./derive"; @@ -300,3 +300,37 @@ describe("ChatStream", () => { expect(scroller.scrollTop).toBe(1000); }); }); + +describe("copy button", () => { + it("copy button writes message text and flashes copied", async () => { + const writeText = vi.fn(() => Promise.resolve()); + Object.assign(navigator, { clipboard: { writeText } }); + render( + , + ); + const btn = screen.getByRole("button", { name: "Copy message" }); + fireEvent.click(btn); + expect(writeText).toHaveBeenCalledWith("copy me"); + await waitFor(() => expect(screen.getByText("copied")).toBeInTheDocument()); + }); + + it("user bubbles get a copy button, toolResults do not", () => { + const { rerender } = render( + , + ); + expect(screen.getByRole("button", { name: "Copy message" })).toBeInTheDocument(); + rerender( + , + ); + expect(screen.queryByRole("button", { name: "Copy message" })).toBeNull(); + }); +}); diff --git a/web/src/ChatStream.tsx b/web/src/ChatStream.tsx index 6afc9b3..e5c9e22 100644 --- a/web/src/ChatStream.tsx +++ b/web/src/ChatStream.tsx @@ -1,133 +1,176 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import type { ChatMessage, ToolState } from "./derive"; const PREVIEW_LEN: number = 120; function oneLine(text: string): string { - const flat = text.replace(/\s+/g, " ").trim(); - return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}…` : flat; + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}…` : flat; +} + +function CopyButton({ text }: { text: string }): React.ReactNode { + const [copied, setCopied] = useState(false); + return ( + + ); } function ToolCard({ tool }: { tool: ToolState }) { - const status: string = tool.running - ? "running…" - : tool.isError - ? "error" - : "done"; - const statusClass: string = tool.isError ? "tool-status-err" : "tool-status-ok"; - return ( -
- - 🛠 {tool.name} - {tool.running ? "working…" : status} - -
-
- args -
{tool.args}
-
-
- result -
{tool.preview}
-
-
-
- ); + const status: string = tool.running + ? "running…" + : tool.isError + ? "error" + : "done"; + const statusClass: string = tool.isError + ? "tool-status-err" + : "tool-status-ok"; + return ( +
+ + 🛠 {tool.name} + + {tool.running ? "working…" : status} + + +
+
+ args +
+						{tool.args}
+					
+
+
+ result +
+						{tool.preview}
+					
+
+
+
+ ); } function Thinking({ text }: { text: string }) { - return ( -
- thinking -
{text}
-
- ); + return ( +
+ thinking +
{text}
+
+ ); } -export function Bubble({ msg, tools }: { msg: ChatMessage; tools: Map }) { - const rowClass = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "system"; - const msgTools: ToolState[] = []; - if (msg.role === "assistant") { - for (const c of msg.toolCalls) { - const t = tools.get(c.id); - if (t !== undefined) msgTools.push(t); - } - } - return ( -
-
- {msg.thinking !== null && msg.thinking.length > 0 && } - {msgTools.map((t) => ( - - ))} - {msg.role === "toolResult" ? ( -
- - result - {oneLine(msg.text)} - -
{msg.text}
-
- ) : ( - msg.text.length > 0 &&
{msg.text}
- )} - {msg.streaming && } -
-
- ); +export function Bubble({ + msg, + tools, +}: { + msg: ChatMessage; + tools: Map; +}) { + const rowClass = + msg.role === "user" + ? "user" + : msg.role === "assistant" + ? "assistant" + : "system"; + const msgTools: ToolState[] = []; + if (msg.role === "assistant") { + for (const c of msg.toolCalls) { + const t = tools.get(c.id); + if (t !== undefined) msgTools.push(t); + } + } + const copyable: boolean = + msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0); + return ( +
+ {copyable && } +
+ {msg.thinking !== null && msg.thinking.length > 0 && ( + + )} + {msgTools.map((t) => ( + + ))} + {msg.role === "toolResult" ? ( +
+ + result + {oneLine(msg.text)} + +
{msg.text}
+
+ ) : ( + msg.text.length > 0 &&
{msg.text}
+ )} + {msg.streaming && } +
+
+ ); } export function TypingIndicator() { - return ( -
-
- - - -
-
- ); + return ( +
+
+ + + +
+
+ ); } interface Props { - messages: ChatMessage[]; - tools: Map; - busy: boolean; + messages: ChatMessage[]; + tools: Map; + busy: boolean; } export default function ChatStream({ messages, tools, busy }: Props) { - const scrollRef = useRef(null); - const pinnedRef = useRef(true); + const scrollRef = useRef(null); + const pinnedRef = useRef(true); - const onScroll = (): void => { - const el = scrollRef.current; - if (el === null) return; - pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80; - }; + const onScroll = (): void => { + const el = scrollRef.current; + if (el === null) return; + pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80; + }; - useEffect(() => { - const el = scrollRef.current; - if (el !== null && pinnedRef.current) el.scrollTop = el.scrollHeight; - }, [messages, busy]); + useEffect(() => { + const el = scrollRef.current; + if (el !== null && pinnedRef.current) el.scrollTop = el.scrollHeight; + }, [messages, busy]); - useEffect(() => { - pinnedRef.current = true; - const el = scrollRef.current; - if (el !== null) el.scrollTop = el.scrollHeight; - }, []); + useEffect(() => { + pinnedRef.current = true; + const el = scrollRef.current; + if (el !== null) el.scrollTop = el.scrollHeight; + }, []); - const last: ChatMessage | undefined = messages[messages.length - 1]; - const streamingOpen: boolean = last !== undefined && last.streaming; - const showTyping: boolean = busy && !streamingOpen; + const last: ChatMessage | undefined = messages[messages.length - 1]; + const streamingOpen: boolean = last !== undefined && last.streaming; + const showTyping: boolean = busy && !streamingOpen; - return ( -
-
- {messages.map((m) => ( - - ))} - {showTyping && } -
-
- ); + return ( +
+
+ {messages.map((m) => ( + + ))} + {showTyping && } +
+
+ ); } diff --git a/web/src/ChatView.test.tsx b/web/src/ChatView.test.tsx index f7a5595..b1d74e7 100644 --- a/web/src/ChatView.test.tsx +++ b/web/src/ChatView.test.tsx @@ -497,3 +497,32 @@ describe("ChatView unknown session", () => { expect(screen.queryByRole("button", { name: /Close pi/ })).toBeNull(); }); }); + +describe("ChatView usage chip", () => { + it("usage chip renders when agent_end carried usage", async () => { + mockFetchJson((url) => { + if (url.startsWith("http://srv/api/sessions/s1/events")) { + seq = 0; + return [ + ...historyEvents(), + ev("agent_end", { usage: { inputTokens: 1200, outputTokens: 340, totalCost: 0.02 } }), + ]; + } + return []; + }); + renderChat(makeStore()); + await screen.findByText("hello there"); + expect(screen.getByText(/↑1,200 ↓340/)).toBeInTheDocument(); + expect(screen.getByText(/\$0\.02/)).toBeInTheDocument(); + }); + + it("usage chip hidden when no usage seen", async () => { + mockFetchJson((url) => { + if (url.startsWith("http://srv/api/sessions/s1/events")) return historyEvents(); + return []; + }); + renderChat(makeStore()); + await screen.findByText("hello there"); + expect(screen.queryByText(/↑\d/)).toBeNull(); + }); +}); diff --git a/web/src/ChatView.tsx b/web/src/ChatView.tsx index 2fdedcc..dfe1719 100644 --- a/web/src/ChatView.tsx +++ b/web/src/ChatView.tsx @@ -167,6 +167,14 @@ export default function ChatView({ store, pushToast }: Props) { {session?.name ?? session?.repo ?? sessionId}
{session?.model}
+ {(chat.usage.inputTokens > 0 || chat.usage.outputTokens > 0) && ( + + ↑{chat.usage.inputTokens.toLocaleString()} ↓ + {chat.usage.outputTokens.toLocaleString()} + {chat.usage.totalCost > 0 && + ` · $${chat.usage.totalCost.toFixed(2)}`} + + )} void }): null { } describe("SessionsView", () => { - it("offline sessions are hidden (active only)", () => { + it("offline sessions appear under the Archive section, not in active", () => { renderView({ sessions: [ session({ id: "on", name: "live" }), @@ -53,14 +53,28 @@ describe("SessionsView", () => { const cards = screen.getAllByRole("button", { name: /^Open session/ }); expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([ "Open session live", + "Open session dead", ]); + expect(screen.getByText("Archive")).toBeInTheDocument(); }); - it("empty state message", () => { - renderView(); + it("empty state message (after load window)", () => { + vi.useFakeTimers(); + renderView({ sessions: [] }); + act(() => { vi.advanceTimersByTime(700); }); expect(screen.getByText(/No active sessions/i)).toBeInTheDocument(); + vi.useRealTimers(); }); + it("skeleton while first load pending", () => { + renderView({ sessions: [] }); + expect(screen.getByRole("heading", { name: "Active sessions" })).toBeInTheDocument(); + const skel = document.querySelector(".skeleton"); + expect(skel).not.toBeNull(); + }); + + + it("renders cards sorted by last activity with fallbacks", () => { renderView({ sessions: [ diff --git a/web/src/SessionsView.tsx b/web/src/SessionsView.tsx index 1af0ba0..62e0dd3 100644 --- a/web/src/SessionsView.tsx +++ b/web/src/SessionsView.tsx @@ -1,4 +1,4 @@ -import { type MouseEvent, useCallback } from "react"; +import { type MouseEvent, useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import type { SessionListItem } from "./protocol"; import { Route } from "./protocol"; @@ -11,12 +11,69 @@ interface Props { pushToast: (text: string) => void; } +function SessionCard({ + s, + onOpen, + onStop, +}: { + s: SessionListItem; + onOpen: (id: string) => void; + onStop: (e: MouseEvent, s: SessionListItem) => void; +}): React.ReactNode { + return ( +
onOpen(s.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") onOpen(s.id); + }} + > + + + + {s.name ?? s.repo ?? s.cwd} + + + {s.repo !== null && {s.repo}} + {s.model} + {relativeTime(s.lastEventAt)} + + + {s.agent && agent} + {!s.agent && s.online && local} + {s.agent && ( + + )} +
+ ); +} + export default function SessionsView({ sessions, onChanged, pushToast, }: Props) { const navigate = useNavigate(); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + // first refresh marks the list as loaded (skeletons until then) + const t = window.setTimeout(() => setLoaded(true), 600); + return () => window.clearTimeout(t); + }, []); const open = useCallback( (id: string): void => { @@ -39,59 +96,58 @@ export default function SessionsView({ } }; - // Only active (online) pi sessions are shown; offline transcripts stay - // reachable by direct URL and are dropped from the default list. - const sorted = [...sessions] - .filter((s) => s.online) - .sort( - (a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt), + const byActivity = (a: SessionListItem, b: SessionListItem): number => + (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt); + const active = sessions.filter((s) => s.online).sort(byActivity); + const archived = sessions.filter((s) => !s.online).sort(byActivity); + + if (!loaded && sessions.length === 0) { + return ( +
+
+

Active sessions

+
+
+
+
+
); + } return (
-

Active sessions

- {sorted.length === 0 && ( +
+

Active sessions

+ + {active.length} active · {archived.length} archived + +
+ {active.length === 0 && (

No active sessions. Spawn one from the sidebar.

)} - {sorted.map((s) => ( -
( + open(s.id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") open(s.id); - }} - > - - - - {s.name ?? s.repo ?? s.cwd} - - - {s.repo !== null && {s.repo}} - {s.model} - {relativeTime(s.lastEventAt)} - - - {s.agent && agent} - {s.agent && ( - - )} -
+ s={s} + onOpen={open} + onStop={(e, ss) => void stop(e, ss)} + /> ))} + {archived.length > 0 && ( + <> +
+ Archive +
+ {archived.map((s) => ( + void stop(e, ss)} + /> + ))} + + )}
); } diff --git a/web/src/SpawnView.test.tsx b/web/src/SpawnView.test.tsx index 3b8e6ac..e805c41 100644 --- a/web/src/SpawnView.test.tsx +++ b/web/src/SpawnView.test.tsx @@ -420,3 +420,29 @@ describe("SpawnView spawn+poll", () => { expect(screen.getByText("g/p: cloning")).toBeInTheDocument(); }); }); + +describe("SpawnSteps", () => { + it("renders progress steps matching job state", async () => { + mockFetchJson((url, init) => { + if (init?.method === "POST" && url.endsWith("/api/spawn")) + return { sessionId: "sp1", containerId: "" }; + if (url.endsWith("/api/gitlab/status")) + return { connected: true, baseUrl: "https://gl", username: "a" }; + if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")]; + return []; + }); + const store = makeStore({ + spawnJobs: [{ sessionId: "sp1", repo: "g/p", state: "building", containerId: "" }], + }); + render(tree(store)); + await flush(); + fireEvent.click(screen.getByText("g/p")); + fireEvent.click(screen.getByLabelText("Spawn container")); + await flush(); + const steps = document.querySelectorAll(".spawn-progress .step"); + expect(steps).toHaveLength(4); + expect(steps[0]?.className).toContain("done"); + expect(steps[1]?.className).toContain("current"); + expect(steps[2]?.className).toBe("step"); + }); +}); diff --git a/web/src/SpawnView.tsx b/web/src/SpawnView.tsx index 2b953ca..600dec1 100644 --- a/web/src/SpawnView.tsx +++ b/web/src/SpawnView.tsx @@ -19,6 +19,27 @@ interface Props { pushToast: (text: string) => void; } + +const SPAWN_STEPS: string[] = ["cloning", "building", "creating", "running"]; + +function SpawnSteps({ state }: { state: string }): React.ReactNode { + const idx = SPAWN_STEPS.indexOf(state); + if (state === "error") return null; + return ( +
+ {SPAWN_STEPS.map((step, i) => ( + i ? "step done" : idx === i ? "step current" : "step" + } + title={step} + /> + ))} +
+ ); +} + export default function SpawnView({ store, pushToast }: Props) { const navigate = useNavigate(); @@ -146,6 +167,13 @@ export default function SpawnView({ store, pushToast }: Props) { }, POLL_MS); }; + const jobState = (): string => { + const job: SpawnJob | undefined = store.spawnJobs.find( + (j) => j.sessionId === spawning?.sessionId, + ); + return job?.state ?? ""; + }; + const jobLine = (): string => { if (spawning === null) return ""; const job: SpawnJob | undefined = store.spawnJobs.find( @@ -208,6 +236,7 @@ export default function SpawnView({ store, pushToast }: Props) { {spawning !== null && (

Spawning…

+

{jobLine()}

container {spawning.containerId.slice(0, 12)} diff --git a/web/src/derive.ts b/web/src/derive.ts index 739a48c..a77dab6 100644 --- a/web/src/derive.ts +++ b/web/src/derive.ts @@ -46,6 +46,7 @@ export interface ChatDerivation { messages: ChatMessage[]; tools: Map; busy: boolean; + usage: { inputTokens: number; outputTokens: number; totalCost: number }; } // The plugin mirrors pi extension events verbatim: tool args arrive as a raw @@ -66,6 +67,7 @@ export function deriveChat(events: EventFrame[]): ChatDerivation { const tools = new Map(); let stream: { id: string; text: string } | null = null; let busy = false; + const usage = { inputTokens: 0, outputTokens: 0, totalCost: 0 }; for (const e of events) { switch (e.type) { @@ -95,6 +97,15 @@ export function deriveChat(events: EventFrame[]): ChatDerivation { case "agent_start": busy = true; break; + case "agent_end": { + const u = e.usage; + if (u !== undefined) { + usage.inputTokens += u.inputTokens ?? 0; + usage.outputTokens += u.outputTokens ?? 0; + usage.totalCost += u.totalCost ?? 0; + } + break; + } case "agent_settled": busy = false; break; @@ -138,7 +149,7 @@ export function deriveChat(events: EventFrame[]): ChatDerivation { }); } - return { messages, tools, busy: busy || stream !== null }; + return { messages, tools, busy: busy || stream !== null, usage }; } // ---------- todo / subagent derivation (client-side, per PROTOCOL.md) ---------- diff --git a/web/src/index.css b/web/src/index.css index 24c6169..b95f9c3 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,468 +1,1145 @@ :root { - color-scheme: dark; - --bg: #212121; - --bg-raised: #2f2f2f; - --bg-hover: #383838; - --border: #3d3d3d; - --text: #ececec; - --text-dim: #a6a6a6; - --text-faint: #7c7c7c; - --accent: #10a37f; - --accent-dim: #0d8a6c; - --danger: #ef4444; - --user-bubble: #2f2f2f; - --assistant-bubble: #212121; - --radius: 12px; - font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + color-scheme: dark; + /* ink / slate design system */ + --bg: #0d1017; + --bg-veil: #11151d; + --bg-raised: #161b26; + --bg-hover: #1c2230; + --bg-active: #202739; + --border: #232a3a; + --border-strong: #2e3750; + --text: #e8ebf2; + --text-dim: #9aa3b8; + --text-faint: #6b7488; + --accent: #6c8cff; + --accent-strong: #8aa2ff; + --accent-soft: rgba(108, 140, 255, 0.14); + --accent-line: rgba(108, 140, 255, 0.35); + --ok: #34d399; + --warn: #fbbf24; + --danger: #f87171; + --danger-soft: rgba(248, 113, 113, 0.12); + --mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; + --radius: 10px; + --radius-lg: 14px; + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-2: 0 8px 28px rgba(0, 0, 0, 0.45); + font-family: + ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, + sans-serif; } -* { box-sizing: border-box; } +* { + box-sizing: border-box; +} -html, body, #root { height: 100%; margin: 0; } +html, +body, +#root { + height: 100%; + margin: 0; +} body { - background: var(--bg); - color: var(--text); - font-size: 15px; - line-height: 1.5; - -webkit-font-smoothing: antialiased; + background: var(--bg); + color: var(--text); + font-size: 14.5px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } button { - font: inherit; - color: inherit; - background: none; - border: none; - cursor: pointer; + font: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; } -button:disabled { cursor: not-allowed; opacity: 0.5; } - -input, textarea, select { - font: inherit; - color: var(--text); - background: var(--bg-raised); - border: 1px solid var(--border); - border-radius: 8px; - padding: 8px 10px; +button:disabled { + cursor: not-allowed; + opacity: 0.45; } -input:focus, textarea:focus, select:focus { outline: 1px solid var(--accent); } -a { color: var(--text-dim); text-decoration: none; } -a:hover { color: var(--text); } +input, +textarea, +select { + font: inherit; + color: var(--text); + background: var(--bg-veil); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 12px; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +a { + color: var(--text-dim); + text-decoration: none; +} +a:hover { + color: var(--text); +} + +::selection { + background: var(--accent-soft); +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} +*::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 6px; + border: 2px solid var(--bg); +} +*::-webkit-scrollbar-track { + background: transparent; +} /* ---------- app shell ---------- */ -.app-shell { display: flex; height: 100%; overflow: hidden; } +.app-shell { + display: flex; + height: 100%; + overflow: hidden; +} .sidebar { - width: 264px; - flex-shrink: 0; - display: flex; - flex-direction: column; - background: var(--bg-raised); - border-right: 1px solid var(--border); + width: 272px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--bg-veil); + border-right: 1px solid var(--border); } .sidebar-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 14px; - font-weight: 600; + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; +} +.sidebar-header .brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 650; + letter-spacing: 0.01em; } -.sidebar-header .brand { display: flex; align-items: center; gap: 8px; } .sidebar-header .brand .logo { - width: 24px; height: 24px; - border-radius: 6px; - background: var(--accent); - color: #fff; - display: flex; align-items: center; justify-content: center; - font-size: 13px; font-weight: 700; + width: 26px; + height: 26px; + border-radius: 8px; + background: linear-gradient(135deg, var(--accent) 0%, #9d6cff 100%); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 800; + box-shadow: var(--shadow-1); } -.conn-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } -.conn-dot.open { background: #22c55e; } -.conn-dot.connecting { background: #eab308; } -.conn-dot.closed { background: var(--danger); } +.conn-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-faint); + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: 999px; +} +.conn-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} +.conn-dot.open { + background: var(--ok); + box-shadow: 0 0 6px rgba(52, 211, 153, 0.6); +} +.conn-dot.connecting { + background: var(--warn); +} +.conn-dot.closed { + background: var(--danger); +} + .new-chat-btn { - display: block; - width: calc(100% - 20px); - margin: 0 10px 8px; - padding: 8px; - border: 1px solid var(--border); - border-radius: 10px; - text-align: center; - color: var(--text); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: calc(100% - 24px); + margin: 0 12px 10px; + padding: 9px; + border: 1px solid var(--accent-line); + border-radius: var(--radius); + text-align: center; + color: var(--accent-strong); + font-weight: 550; + background: var(--accent-soft); + transition: background 0.15s ease; +} +.new-chat-btn:hover { + background: rgba(108, 140, 255, 0.22); +} +.new-chat-btn.active { + background: rgba(108, 140, 255, 0.22); } -.new-chat-btn:hover { background: var(--bg-hover); } -.sidebar-sessions { flex: 1; overflow-y: auto; padding: 4px 8px 12px; } +.sidebar-sessions { + flex: 1; + overflow-y: auto; + padding: 2px 12px 12px; +} +.sidebar-section-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-faint); + padding: 12px 6px 6px; +} .session-link { - display: block; - padding: 8px 10px; - border-radius: 8px; - color: var(--text-dim); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + color: var(--text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: 1px solid transparent; } -.session-link:hover { background: var(--bg-hover); color: var(--text); } -.session-link.active { background: var(--bg-hover); color: var(--text); } -.sidebar-footer { - padding: 10px; - border-top: 1px solid var(--border); - display: flex; - gap: 8px; - align-items: center; +.session-link:hover { + background: var(--bg-hover); + color: var(--text); } -.sidebar-footer .spacer { flex: 1; } -.icon-btn { - padding: 6px; - border-radius: 8px; - color: var(--text-dim); - font-size: 13px; +.session-link.active { + background: var(--bg-active); + color: var(--text); + border-color: var(--border); +} +.session-link.archived { + opacity: 0.75; +} +.session-link .link-label { + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} +.sidebar-empty { + color: var(--text-faint); + font-size: 12.5px; + padding: 4px 8px; } -.icon-btn:hover { background: var(--bg-hover); color: var(--text); } -.icon-btn.danger:hover { color: var(--danger); } -.main { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; } +.sidebar-footer { + padding: 10px 12px; + border-top: 1px solid var(--border); + display: flex; + gap: 8px; + align-items: center; +} +.sidebar-footer .spacer { + flex: 1; +} +.icon-btn { + padding: 6px 10px; + border-radius: 8px; + color: var(--text-dim); + font-size: 13px; + transition: + background 0.12s ease, + color 0.12s ease; +} +.icon-btn:hover { + background: var(--bg-hover); + color: var(--text); +} +.icon-btn.danger:hover { + color: var(--danger); + background: var(--danger-soft); +} + +.main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; + position: relative; +} + +.conn-banner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 6px 16px; + background: rgba(251, 191, 36, 0.09); + border-bottom: 1px solid rgba(251, 191, 36, 0.25); + color: var(--warn); + font-size: 12.5px; +} /* ---------- sessions view ---------- */ -.page { flex: 1; overflow-y: auto; padding: 24px; max-width: 860px; margin: 0 auto; width: 100%; } -.page h1 { font-size: 20px; margin: 0 0 16px; } -.page .empty { color: var(--text-faint); padding: 40px 0; text-align: center; } +.page { + flex: 1; + overflow-y: auto; + padding: 28px 24px 48px; + max-width: 880px; + margin: 0 auto; + width: 100%; +} +.page-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 18px; +} +.page h1 { + font-size: 19px; + margin: 0; + font-weight: 650; + letter-spacing: -0.01em; +} +.page-head .count { + color: var(--text-faint); + font-size: 13px; +} +.page .empty { + color: var(--text-faint); + padding: 56px 0; + text-align: center; +} .session-card { - display: flex; - align-items: center; - gap: 12px; - padding: 14px; - border: 1px solid var(--border); - border-radius: var(--radius); - margin-bottom: 10px; - cursor: pointer; - background: var(--bg); + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + margin-bottom: 10px; + cursor: pointer; + background: var(--bg-raised); + box-shadow: var(--shadow-1); + transition: + border-color 0.15s ease, + transform 0.1s ease; +} +.session-card:hover { + border-color: var(--border-strong); + transform: translateY(-1px); } -.session-card:hover { background: var(--bg-raised); } .session-card .online-dot { - width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; - background: #525252; + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; + background: var(--text-faint); +} +.session-card .online-dot.on { + background: var(--ok); + box-shadow: 0 0 8px rgba(52, 211, 153, 0.55); +} +.session-card .card-body { + flex: 1; + min-width: 0; } -.session-card .online-dot.on { background: #22c55e; } -.session-card .card-body { flex: 1; min-width: 0; } .session-card .card-title { - font-weight: 600; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 14.5px; } .session-card .card-meta { - font-size: 12px; color: var(--text-faint); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - display: flex; gap: 8px; flex-wrap: wrap; + font-size: 12px; + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 2px; } .badge { - font-size: 11px; - padding: 2px 8px; - border-radius: 999px; - border: 1px solid var(--border); - color: var(--text-dim); - flex-shrink: 0; + font-size: 10.5px; + font-weight: 600; + padding: 2.5px 8px; + border-radius: 999px; + border: 1px solid var(--accent-line); + color: var(--accent-strong); + background: var(--accent-soft); + flex-shrink: 0; + letter-spacing: 0.02em; +} +.badge.neutral { + border-color: var(--border); + color: var(--text-dim); + background: transparent; +} + +.skeleton { + border-radius: var(--radius-lg); + background: linear-gradient( + 100deg, + var(--bg-raised) 40%, + var(--bg-hover) 50%, + var(--bg-raised) 60% + ); + background-size: 200% 100%; + animation: shimmer 1.4s infinite linear; + height: 74px; + margin-bottom: 10px; + border: 1px solid var(--border); +} +@keyframes shimmer { + to { + background-position: -200% 0; + } } /* ---------- chat view ---------- */ -.chat-layout { flex: 1; display: flex; min-height: 0; } -.chat-col { flex: 1; display: flex; flex-direction: column; min-width: 0; } +.chat-layout { + flex: 1; + display: flex; + min-height: 0; +} +.chat-col { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} .chat-header { - display: flex; align-items: center; gap: 10px; - padding: 10px 16px; - border-bottom: 1px solid var(--border); - background: var(--bg); + display: flex; + align-items: center; + gap: 10px; + padding: 10px 18px; + border-bottom: 1px solid var(--border); + background: var(--bg-veil); + min-height: 54px; +} +.chat-header .title { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 14.5px; +} +.chat-header .sub { + font-size: 12px; + color: var(--text-faint); + white-space: nowrap; +} +.chat-header .spacer, +.task-col .spacer { + flex: 1; +} +.usage-chip { + font-size: 11.5px; + color: var(--text-faint); + font-family: var(--mono); + border: 1px solid var(--border); + padding: 2.5px 8px; + border-radius: 999px; + white-space: nowrap; } -.chat-header .title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.chat-header .sub { font-size: 12px; color: var(--text-faint); } -.chat-header .spacer, .task-col .spacer { flex: 1; } -.chat-scroll { flex: 1; overflow-y: auto; padding: 20px 16px; scroll-behavior: smooth; } -.chat-inner { max-width: 760px; margin: 0 auto; } +.chat-scroll { + flex: 1; + overflow-y: auto; + padding: 22px 18px 30px; + scroll-behavior: smooth; +} +.chat-inner { + max-width: 780px; + margin: 0 auto; +} -.bubble-row { display: flex; margin-bottom: 14px; } -.bubble-row.user { justify-content: flex-end; } +.bubble-row { + display: flex; + margin-bottom: 16px; + position: relative; +} +.bubble-row.user { + justify-content: flex-end; +} .bubble { - max-width: 86%; - padding: 10px 14px; - border-radius: var(--radius); - white-space: pre-wrap; - word-break: break-word; + max-width: 86%; + padding: 10px 14px; + border-radius: var(--radius-lg); + white-space: pre-wrap; + word-break: break-word; +} +.bubble-row.user .bubble { + background: var(--bg-active); + border: 1px solid var(--border-strong); + border-bottom-right-radius: 4px; +} +.bubble-row.assistant .bubble { + background: transparent; + padding: 0; + max-width: 100%; +} +.bubble-row.system .bubble { + color: var(--text-faint); + font-size: 13px; + font-style: italic; +} + +.msg-copy { + position: absolute; + top: -6px; + right: -4px; + opacity: 0; + font-size: 11px; + color: var(--text-faint); + border: 1px solid var(--border); + background: var(--bg-raised); + border-radius: 6px; + padding: 2px 7px; + transition: opacity 0.12s ease; +} +.bubble-row:hover .msg-copy { + opacity: 1; +} +.bubble-row.user .msg-copy { + right: auto; + left: -4px; +} +.msg-copy:hover { + color: var(--text); + border-color: var(--border-strong); } -.bubble-row.user .bubble { background: var(--user-bubble); } -.bubble-row.assistant .bubble { background: transparent; padding: 0; max-width: 100%; } -.bubble-row.system .bubble { color: var(--text-faint); font-size: 13px; font-style: italic; } .bubble .tool-card { - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg-raised); - margin: 8px 0; - overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-raised); + margin: 10px 0; + overflow: hidden; } .bubble .tool-card summary { - cursor: pointer; - padding: 8px 12px; - font-size: 13px; - color: var(--text-dim); - display: flex; align-items: center; gap: 8px; - list-style: none; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + cursor: pointer; + padding: 9px 13px; + font-size: 12.5px; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 8px; + list-style: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.bubble .tool-card summary::-webkit-details-marker { + display: none; +} +.bubble .tool-card summary .tool-name { + font-family: var(--mono); + font-size: 12px; + color: var(--text); } -.bubble .tool-card summary::-webkit-details-marker { display: none; } .bubble .tool-card .tool-body { - border-top: 1px solid var(--border); - padding: 10px 12px; - font-size: 13px; - color: var(--text-dim); - white-space: pre-wrap; - word-break: break-word; - max-height: 300px; - overflow-y: auto; + border-top: 1px solid var(--border); + padding: 11px 13px; + font-size: 12.5px; + color: var(--text-dim); + white-space: pre-wrap; + word-break: break-word; + max-height: 320px; + overflow-y: auto; + font-family: var(--mono); + background: var(--bg-veil); +} +.tool-status-ok { + color: var(--ok); + font-weight: 600; +} +.tool-status-err { + color: var(--danger); + font-weight: 600; } -.tool-status-ok { color: var(--accent); } -.tool-status-err { color: var(--danger); } -details.thinking { margin-bottom: 8px; } +details.thinking { + margin-bottom: 10px; +} details.thinking summary { - cursor: pointer; - font-size: 12px; - color: var(--text-faint); - list-style: none; + cursor: pointer; + font-size: 12px; + color: var(--text-faint); + list-style: none; + user-select: none; +} +details.thinking summary::-webkit-details-marker { + display: none; +} +details.thinking summary::before { + content: "◆ "; + font-size: 10px; +} +details.thinking summary:hover { + color: var(--text-dim); } -details.thinking summary::-webkit-details-marker { display: none; } -details.thinking summary::before { content: "◆ "; } details.thinking .thinking-body { - font-size: 13px; - color: var(--text-faint); - border-left: 2px solid var(--border); - padding-left: 10px; - margin-top: 6px; - white-space: pre-wrap; - max-height: 260px; - overflow-y: auto; + font-size: 12.5px; + color: var(--text-faint); + border-left: 2px solid var(--border-strong); + padding-left: 12px; + margin-top: 8px; + white-space: pre-wrap; + max-height: 280px; + overflow-y: auto; } .typing { - display: inline-flex; gap: 4px; align-items: center; - padding: 8px 0; + display: inline-flex; + gap: 4px; + align-items: center; + padding: 10px 0 4px; } .typing .dot { - width: 6px; height: 6px; border-radius: 50%; - background: var(--text-faint); - animation: typing-bounce 1.2s infinite; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-faint); + animation: typing-bounce 1.2s infinite; +} +.typing .dot:nth-child(2) { + animation-delay: 0.15s; +} +.typing .dot:nth-child(3) { + animation-delay: 0.3s; } -.typing .dot:nth-child(2) { animation-delay: 0.15s; } -.typing .dot:nth-child(3) { animation-delay: 0.3s; } @keyframes typing-bounce { - 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; } - 30% { transform: translateY(-4px); opacity: 1; } + 0%, + 60%, + 100% { + transform: translateY(0); + opacity: 0.45; + } + 30% { + transform: translateY(-4px); + opacity: 1; + } } .composer { - border-top: 1px solid var(--border); - padding: 12px 16px; - background: var(--bg); + border-top: 1px solid var(--border); + padding: 14px 18px 16px; + background: var(--bg-veil); +} +.composer-inner { + max-width: 780px; + margin: 0 auto; + display: flex; + gap: 10px; + align-items: flex-end; } -.composer-inner { max-width: 760px; margin: 0 auto; display: flex; gap: 8px; align-items: flex-end; } .composer textarea { - flex: 1; - resize: none; - max-height: 200px; - min-height: 42px; - border-radius: var(--radius); + flex: 1; + resize: none; + max-height: 200px; + min-height: 44px; + border-radius: var(--radius-lg); + background: var(--bg-raised); } .composer .send-btn { - background: var(--accent); - color: #fff; - border-radius: 10px; - padding: 10px 14px; + background: var(--accent); + color: #fff; + border-radius: var(--radius-lg); + padding: 11px 16px; + font-weight: 600; + transition: background 0.15s ease; +} +.composer .send-btn:hover { + background: var(--accent-strong); +} +.composer .send-btn:disabled { + background: var(--bg-hover); + color: var(--text-faint); } -.composer .send-btn:hover { background: var(--accent-dim); } -.composer .send-btn:disabled { background: var(--bg-hover); color: var(--text-faint); } .composer .abort-btn { - border: 1px solid var(--border); - border-radius: 10px; - padding: 10px 14px; - color: var(--danger); + border: 1px solid rgba(248, 113, 113, 0.35); + border-radius: var(--radius-lg); + padding: 11px 16px; + color: var(--danger); +} +.composer .abort-btn:hover { + background: var(--danger-soft); } /* ---------- task panel ---------- */ .task-col { - width: 300px; - flex-shrink: 0; - border-left: 1px solid var(--border); - overflow-y: auto; - padding: 14px; - background: var(--bg); + width: 312px; + flex-shrink: 0; + border-left: 1px solid var(--border); + overflow-y: auto; + padding: 16px; + background: var(--bg-veil); +} +.task-col h2 { + font-size: 11px; + margin: 0 0 10px; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; +} +.task-col .empty { + color: var(--text-faint); + font-size: 12.5px; + padding: 8px 0; +} +.task-section { + margin-bottom: 20px; +} +.task-card { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-raised); + padding: 10px 12px; +} +.task-card + .task-card { + margin-top: 8px; } -.task-col h2 { font-size: 13px; margin: 0 0 8px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.04em; } -.task-col .empty { color: var(--text-faint); font-size: 13px; } -.task-section { margin-bottom: 18px; } -.todo-item { display: flex; gap: 8px; align-items: baseline; font-size: 13px; padding: 3px 0; } -.todo-item .todo-icon { flex-shrink: 0; width: 14px; text-align: center; } -.todo-item .todo-icon.pending { color: var(--text-faint); } -.todo-item .todo-icon.in-progress { color: #eab308; } -.todo-item .todo-icon.completed { color: var(--accent); } -.todo-item.done .todo-text { text-decoration: line-through; color: var(--text-faint); } -.todo-item .todo-text { color: var(--text); } +.todo-item { + display: flex; + gap: 8px; + align-items: baseline; + font-size: 13px; + padding: 3.5px 0; +} +.todo-item .todo-icon { + flex-shrink: 0; + width: 14px; + text-align: center; +} +.todo-item .todo-icon.pending { + color: var(--text-faint); +} +.todo-item .todo-icon.in-progress { + color: var(--warn); +} +.todo-item .todo-icon.completed { + color: var(--ok); +} +.todo-item.done .todo-text { + text-decoration: line-through; + color: var(--text-faint); +} +.todo-item .todo-text { + color: var(--text); +} -.subagent-item { font-size: 13px; padding: 4px 0; display: flex; gap: 8px; align-items: center; } +.subagent-item { + font-size: 13px; + padding: 6px 10px; + display: flex; + gap: 8px; + align-items: center; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-raised); + margin-bottom: 6px; +} .subagent-item .spinner { - width: 12px; height: 12px; - border: 2px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; - flex-shrink: 0; + width: 12px; + height: 12px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + flex-shrink: 0; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.subagent-item .done-icon { + color: var(--ok); + flex-shrink: 0; +} +.subagent-item .err-icon { + color: var(--danger); + flex-shrink: 0; +} +.working-line { + font-size: 12px; + color: var(--text-faint); + padding: 3px 0; + display: flex; + gap: 6px; + align-items: center; + font-family: var(--mono); } -@keyframes spin { to { transform: rotate(360deg); } } -.subagent-item .done-icon { color: var(--accent); flex-shrink: 0; } -.working-line { font-size: 12px; color: var(--text-faint); padding: 3px 0; display: flex; gap: 6px; align-items: center; } -.task-toggle { display: none; } +.task-toggle { + display: none; +} /* ---------- settings gate ---------- */ .gate { - height: 100%; - display: flex; - align-items: center; - justify-content: center; - padding: 20px; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: + radial-gradient( + 60% 50% at 50% 0%, + rgba(108, 140, 255, 0.07) 0%, + transparent 70% + ), + var(--bg); } .gate-card { - width: 100%; - max-width: 380px; - border: 1px solid var(--border); - border-radius: 16px; - background: var(--bg-raised); - padding: 28px; + width: 100%; + max-width: 400px; + border: 1px solid var(--border-strong); + border-radius: 18px; + background: var(--bg-raised); + padding: 30px; + box-shadow: var(--shadow-2); +} +.gate-card h1 { + font-size: 19px; + margin: 0 0 4px; + display: flex; + align-items: center; + gap: 10px; + font-weight: 650; +} +.gate-card h1 .logo { + width: 30px; + height: 30px; + border-radius: 9px; + background: linear-gradient(135deg, var(--accent) 0%, #9d6cff 100%); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 800; +} +.gate-card .sub { + color: var(--text-faint); + font-size: 13px; + margin-bottom: 22px; +} +.gate-card label { + display: block; + font-size: 12.5px; + font-weight: 600; + color: var(--text-dim); + margin: 14px 0 5px; +} +.gate-card input { + width: 100%; +} +.gate-card .gate-error { + color: var(--danger); + font-size: 13px; + margin-top: 12px; + min-height: 18px; } -.gate-card h1 { font-size: 18px; margin: 0 0 4px; display: flex; align-items: center; gap: 8px; } -.gate-card .sub { color: var(--text-faint); font-size: 13px; margin-bottom: 20px; } -.gate-card label { display: block; font-size: 13px; color: var(--text-dim); margin: 12px 0 4px; } -.gate-card input { width: 100%; } -.gate-card .gate-error { color: var(--danger); font-size: 13px; margin-top: 12px; min-height: 18px; } .gate-card .gate-submit { - width: 100%; - margin-top: 20px; - padding: 10px; - background: var(--accent); - color: #fff; - border-radius: 10px; + width: 100%; + margin-top: 22px; + padding: 11px; + background: var(--accent); + color: #fff; + border-radius: var(--radius); + font-weight: 600; +} +.gate-card .gate-submit:hover { + background: var(--accent-strong); } /* ---------- spawn view ---------- */ .spawn-card { - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 18px; - margin-bottom: 16px; - background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; + margin-bottom: 14px; + background: var(--bg-raised); + box-shadow: var(--shadow-1); +} +.spawn-card h2 { + font-size: 14px; + margin: 0 0 12px; + font-weight: 650; +} +.spawn-card input[type="text"], +.spawn-card input[type="password"], +.spawn-card select { + width: 100%; +} +.spawn-card .row { + display: flex; + gap: 10px; +} +.spawn-card .row > * { + flex: 1; +} +.spawn-card .actions { + display: flex; + gap: 8px; + margin-top: 14px; } -.spawn-card h2 { font-size: 15px; margin: 0 0 10px; } -.spawn-card input[type="text"], .spawn-card input[type="password"], .spawn-card select { width: 100%; } -.spawn-card .row { display: flex; gap: 10px; } -.spawn-card .row > * { flex: 1; } -.spawn-card .actions { display: flex; gap: 8px; margin-top: 14px; } .btn-primary { - background: var(--accent); - color: #fff; - border-radius: 10px; - padding: 8px 16px; + background: var(--accent); + color: #fff; + border-radius: var(--radius); + padding: 9px 18px; + font-weight: 600; +} +.btn-primary:hover { + background: var(--accent-strong); +} +.btn-primary:disabled { + background: var(--bg-hover); + color: var(--text-faint); +} +.btn-secondary { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 9px 18px; +} +.btn-secondary:hover { + background: var(--bg-hover); +} +.error-text { + color: var(--danger); + font-size: 13px; + margin-top: 10px; } -.btn-primary:hover { background: var(--accent-dim); } -.btn-primary:disabled { background: var(--bg-hover); color: var(--text-faint); } -.btn-secondary { border: 1px solid var(--border); border-radius: 10px; padding: 8px 16px; } -.btn-secondary:hover { background: var(--bg-hover); } -.error-text { color: var(--danger); font-size: 13px; margin-top: 10px; } -.repo-list { max-height: 420px; overflow-y: auto; } +.repo-list { + max-height: 440px; + overflow-y: auto; + margin: 0 -6px; + padding: 0 6px; +} .repo-item { - display: flex; align-items: center; gap: 10px; - padding: 10px; - border-radius: 8px; - cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + padding: 11px 12px; + border-radius: var(--radius); + cursor: pointer; + border: 1px solid transparent; +} +.repo-item:hover { + background: var(--bg-hover); +} +.repo-item.selected { + background: var(--accent-soft); + border-color: var(--accent-line); +} +.repo-item .repo-path { + font-weight: 600; + font-size: 13.5px; +} +.repo-item .repo-meta { + font-size: 12px; + color: var(--text-faint); + margin-top: 1px; } -.repo-item:hover { background: var(--bg-hover); } -.repo-item.selected { background: var(--bg-hover); outline: 1px solid var(--accent); } -.repo-item .repo-path { font-weight: 600; } -.repo-item .repo-meta { font-size: 12px; color: var(--text-faint); } -.spawn-job-line { font-size: 13px; color: var(--text-dim); padding: 4px 0; } +.spawn-job-line { + font-size: 13px; + color: var(--text-dim); + padding: 4px 0; + font-family: var(--mono); +} +.spawn-progress { + display: flex; + gap: 6px; + margin: 10px 0 4px; +} +.spawn-progress .step { + height: 4px; + flex: 1; + border-radius: 2px; + background: var(--border); + transition: background 0.3s ease; +} +.spawn-progress .step.done { + background: var(--accent); +} +.spawn-progress .step.current { + background: var(--accent); + opacity: 0.45; + animation: pulse-step 1.2s infinite; +} +@keyframes pulse-step { + 50% { + opacity: 0.2; + } +} + +/* ---------- error boundary ---------- */ + +.err-boundary { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 10px; + color: var(--text-dim); + padding: 40px; + text-align: center; +} +.err-boundary h2 { + color: var(--text); + margin: 0; +} +.err-boundary pre { + font-size: 12px; + color: var(--text-faint); + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; + max-width: 560px; + max-height: 200px; + overflow: auto; + text-align: left; +} /* ---------- toasts ---------- */ .toasts { - position: fixed; - bottom: 16px; - left: 50%; - transform: translateX(-50%); - display: flex; - flex-direction: column; - gap: 8px; - z-index: 100; + position: fixed; + top: 14px; + right: 14px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 100; + max-width: min(360px, calc(100vw - 28px)); } .toast { - background: var(--bg-raised); - border: 1px solid var(--border); - color: var(--text); - padding: 10px 16px; - border-radius: 10px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); - font-size: 14px; + background: var(--bg-raised); + border: 1px solid var(--border-strong); + color: var(--text); + padding: 10px 14px; + border-radius: var(--radius); + box-shadow: var(--shadow-2); + font-size: 13.5px; + animation: toast-in 0.18s ease; +} +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(-6px); + } } -/* ---------- mobile ≤400px ---------- */ +/* ---------- mobile ---------- */ @media (max-width: 860px) { - .task-col { display: none; } - .task-toggle { - display: inline-flex; - align-items: center; - } - .mobile-tasks { - border-bottom: 1px solid var(--border); - padding: 12px 16px; - background: var(--bg-raised); - } + .task-col { + display: none; + } + .task-toggle { + display: inline-flex; + align-items: center; + } + .mobile-tasks { + border-bottom: 1px solid var(--border); + padding: 12px 16px; + background: var(--bg-raised); + } } @media (max-width: 700px) { - .sidebar { - position: fixed; - inset: 0 auto 0 0; - z-index: 50; - transform: translateX(-100%); - transition: transform 0.2s ease; - width: 280px; - } - .sidebar.open { transform: translateX(0); } - .sidebar-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 40; - } - .menu-btn { display: inline-flex !important; } - .page { padding: 14px; } - .chat-scroll { padding: 14px 10px; } - .bubble { max-width: 94%; } + .sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 50; + transform: translateX(-100%); + transition: transform 0.22s ease; + width: 288px; + box-shadow: var(--shadow-2); + } + .sidebar.open { + transform: translateX(0); + } + .sidebar-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 40; + backdrop-filter: blur(2px); + } + .menu-btn { + display: inline-flex !important; + } + .page { + padding: 16px 14px 40px; + } + .chat-scroll { + padding: 16px 12px 24px; + } + .bubble { + max-width: 94%; + } + .usage-chip { + display: none; + } + .toasts { + top: auto; + bottom: 14px; + right: 14px; + } } @media (max-width: 400px) { - body { font-size: 14px; } - .sidebar { width: 100%; } - .gate-card { padding: 20px; } - .session-card { padding: 10px; gap: 8px; } - .composer textarea { font-size: 16px; } /* prevent iOS zoom */ - .composer .send-btn { padding: 10px 12px; } + body { + font-size: 14px; + } + .sidebar { + width: 100%; + } + .gate-card { + padding: 22px; + } + .session-card { + padding: 12px; + gap: 10px; + } + .composer textarea { + font-size: 16px; + } /* prevent iOS zoom */ + .composer .send-btn { + padding: 11px 13px; + } }