Initial Coworker app scaffold
This commit is contained in:
86
apps/desktop/src/api.ts
Normal file
86
apps/desktop/src/api.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type {
|
||||
AgentRunResult,
|
||||
AppState,
|
||||
BrainInput,
|
||||
BrainItem,
|
||||
ModelProvider,
|
||||
PairingChallenge,
|
||||
ProviderProbeResult,
|
||||
WebAccessSettings,
|
||||
WorkspaceFile,
|
||||
WorkspaceState
|
||||
} from "@coworker/core";
|
||||
|
||||
type AgentRequest = {
|
||||
prompt: string;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
workspace?: WorkspaceState;
|
||||
messages?: Array<{ role: string; content: string }>;
|
||||
};
|
||||
|
||||
async function json<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const coworkerApi = {
|
||||
state(): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.state() as Promise<AppState>;
|
||||
return json<AppState>("/api/state");
|
||||
},
|
||||
saveProvider(provider: ModelProvider): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.providers.save(provider) as Promise<AppState>;
|
||||
return json<AppState>("/api/providers", { method: "POST", body: JSON.stringify(provider) });
|
||||
},
|
||||
probeProvider(id: string): Promise<ProviderProbeResult> {
|
||||
if (window.coworker) return window.coworker.providers.probe(id) as Promise<ProviderProbeResult>;
|
||||
return json<ProviderProbeResult>(`/api/providers/${id}/probe`, { method: "POST" });
|
||||
},
|
||||
addBrain(input: BrainInput): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.brain.add(input) as Promise<AppState>;
|
||||
return json<AppState>("/api/brain", { method: "POST", body: JSON.stringify(input) });
|
||||
},
|
||||
removeBrain(id: string): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.brain.remove(id) as Promise<AppState>;
|
||||
return json<AppState>(`/api/brain/${id}`, { method: "DELETE" });
|
||||
},
|
||||
openWorkspace(): Promise<WorkspaceState | undefined> {
|
||||
if (window.coworker) return window.coworker.workspace.open() as Promise<WorkspaceState | undefined>;
|
||||
throw new Error("Workspace selection is available in the desktop app.");
|
||||
},
|
||||
listWorkspace(id: string): Promise<WorkspaceFile[]> {
|
||||
if (window.coworker) return window.coworker.workspace.list(id) as Promise<WorkspaceFile[]>;
|
||||
return json<WorkspaceFile[]>(`/api/workspaces/${id}/files`);
|
||||
},
|
||||
readWorkspace(id: string, relativePath: string): Promise<string> {
|
||||
if (window.coworker) return window.coworker.workspace.read(id, relativePath) as Promise<string>;
|
||||
return json<{ content: string }>(`/api/workspaces/${id}/files/${encodeURIComponent(relativePath)}`).then((r) => r.content);
|
||||
},
|
||||
runAgent(request: AgentRequest): Promise<AgentRunResult> {
|
||||
if (window.coworker) return window.coworker.agent.run(request) as Promise<AgentRunResult>;
|
||||
return json<AgentRunResult>("/api/agent/run", { method: "POST", body: JSON.stringify(request) });
|
||||
},
|
||||
saveWebSettings(settings: WebAccessSettings): Promise<AppState> {
|
||||
if (window.coworker) return window.coworker.web.saveSettings(settings) as Promise<AppState>;
|
||||
throw new Error("Web settings are available in the desktop app.");
|
||||
},
|
||||
createPairing(): Promise<PairingChallenge> {
|
||||
if (window.coworker) return window.coworker.web.createPairing() as Promise<PairingChallenge>;
|
||||
throw new Error("Pairing is started from the desktop app.");
|
||||
},
|
||||
pair(code: string, name: string): Promise<{ token: string }> {
|
||||
return json<{ token: string }>("/api/pair", { method: "POST", body: JSON.stringify({ code, name }) });
|
||||
}
|
||||
};
|
||||
|
||||
export function displayBrainItem(item: BrainItem) {
|
||||
return `${item.kind.toUpperCase()} / ${item.scope}${item.pinned ? " / PINNED" : ""}`;
|
||||
}
|
||||
7
apps/desktop/src/global.d.ts
vendored
Normal file
7
apps/desktop/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { CoworkerApi } from "../electron/preload";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
coworker?: CoworkerApi;
|
||||
}
|
||||
}
|
||||
310
apps/desktop/src/main.tsx
Normal file
310
apps/desktop/src/main.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppState, BrainItem, ChatMessage, ModelProvider, WorkspaceFile, WorkspaceState } from "@coworker/core";
|
||||
import { createProvider } from "@coworker/core";
|
||||
import { coworkerApi, displayBrainItem } from "./api";
|
||||
import "./styles.css";
|
||||
|
||||
type View = "agent" | "models" | "brain" | "files" | "settings";
|
||||
|
||||
const emptyState: AppState = {
|
||||
providers: [],
|
||||
brain: [],
|
||||
workspaces: [],
|
||||
webAccess: { enabled: false, bindHost: "127.0.0.1", port: 4789, requirePairing: true },
|
||||
pairedDevices: []
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState<AppState>(emptyState);
|
||||
const [view, setView] = useState<View>("agent");
|
||||
const [error, setError] = useState("");
|
||||
const [workspace, setWorkspace] = useState<WorkspaceState | undefined>();
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
setState(await coworkerApi.state());
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load Coworker state.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
if ("serviceWorker" in navigator) void navigator.serviceWorker.register("/sw.js").catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspace(state.workspaces[0]);
|
||||
}, [state.workspaces]);
|
||||
|
||||
const activeProvider = state.providers.find((provider) => provider.enabled) ?? state.providers[0];
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span className="brand-mark">C</span>
|
||||
<div>
|
||||
<strong>Coworker</strong>
|
||||
<span>Local coding app</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{(["agent", "files", "models", "brain", "settings"] as View[]).map((item) => (
|
||||
<button key={item} className={view === item ? "active" : ""} onClick={() => setView(item)}>
|
||||
{label(item)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="status">
|
||||
<span>Model</span>
|
||||
<strong>{activeProvider?.name ?? "Not configured"}</strong>
|
||||
</div>
|
||||
<button className="primary" onClick={async () => setWorkspace(await coworkerApi.openWorkspace())}>
|
||||
Open project
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section className="content">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<h1>{label(view)}</h1>
|
||||
<p>{subtitle(view, workspace)}</p>
|
||||
</div>
|
||||
{error ? <span className="error">{error}</span> : <span className="ok">Ready</span>}
|
||||
</header>
|
||||
|
||||
{view === "agent" && <AgentView state={state} workspace={workspace} onError={setError} />}
|
||||
{view === "files" && <FilesView workspace={workspace} onError={setError} />}
|
||||
{view === "models" && <ModelsView providers={state.providers} onState={setState} onError={setError} />}
|
||||
{view === "brain" && <BrainView items={state.brain} onState={setState} onError={setError} />}
|
||||
{view === "settings" && <SettingsView state={state} onState={setState} onError={setError} />}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentView({ state, workspace, onError }: { state: AppState; workspace?: WorkspaceState; onError: (value: string) => void }) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const provider = state.providers.find((item) => item.enabled) ?? state.providers[0];
|
||||
const model = provider?.defaults.coding ?? provider?.defaults.chat;
|
||||
|
||||
async function run() {
|
||||
if (!prompt.trim()) return;
|
||||
setRunning(true);
|
||||
const userMessage: ChatMessage = {
|
||||
id: `local_${Date.now()}`,
|
||||
role: "user",
|
||||
content: prompt,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
setMessages((current) => [...current, userMessage]);
|
||||
setPrompt("");
|
||||
try {
|
||||
const result = await coworkerApi.runAgent({ prompt, providerId: provider?.id, model, workspace, messages });
|
||||
setMessages((current) => [...current, result.assistantMessage]);
|
||||
onError("");
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Agent failed.");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="agent-grid">
|
||||
<section className="conversation">
|
||||
{messages.length === 0 ? (
|
||||
<div className="empty">
|
||||
<h2>Start a coding session</h2>
|
||||
<p>Open a project, pick a local model, and ask Coworker to inspect, plan, or propose changes.</p>
|
||||
</div>
|
||||
) : messages.map((message) => (
|
||||
<article key={message.id} className={`message ${message.role}`}>
|
||||
<span>{message.role}</span>
|
||||
<pre>{message.content}</pre>
|
||||
</article>
|
||||
))}
|
||||
<div className="composer">
|
||||
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} placeholder="Ask Coworker to work on this project..." />
|
||||
<button className="primary" disabled={running} onClick={() => void run()}>{running ? "Running" : "Send"}</button>
|
||||
</div>
|
||||
</section>
|
||||
<aside className="inspector">
|
||||
<h2>Session</h2>
|
||||
<dl>
|
||||
<dt>Project</dt>
|
||||
<dd>{workspace?.name ?? "None"}</dd>
|
||||
<dt>Provider</dt>
|
||||
<dd>{provider?.name ?? "None"}</dd>
|
||||
<dt>Model</dt>
|
||||
<dd>{model ?? "Choose in Models"}</dd>
|
||||
</dl>
|
||||
<h2>Brain recall</h2>
|
||||
<ul className="compact-list">
|
||||
{state.brain.filter((item) => item.pinned).slice(0, 6).map((item) => <li key={item.id}>{item.title}</li>)}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilesView({ workspace, onError }: { workspace?: WorkspaceState; onError: (value: string) => void }) {
|
||||
const [files, setFiles] = useState<WorkspaceFile[]>([]);
|
||||
const [preview, setPreview] = useState("");
|
||||
useEffect(() => {
|
||||
if (!workspace) return;
|
||||
coworkerApi.listWorkspace(workspace.id).then(setFiles).catch((err) => onError(err instanceof Error ? err.message : "File list failed."));
|
||||
}, [workspace, onError]);
|
||||
return (
|
||||
<div className="split">
|
||||
<section className="list-panel">
|
||||
{files.map((file) => (
|
||||
<button key={file.path} onClick={async () => {
|
||||
if (file.type === "directory" || !workspace) return;
|
||||
setPreview(await coworkerApi.readWorkspace(workspace.id, file.path));
|
||||
}}>
|
||||
<span>{file.type === "directory" ? "DIR" : "FILE"}</span>
|
||||
{file.path}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
<pre className="preview">{preview || "Open a text file to preview it."}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelsView({ providers, onState, onError }: { providers: ModelProvider[]; onState: (state: AppState) => void; onError: (value: string) => void }) {
|
||||
const [draft, setDraft] = useState<ModelProvider>(() => createProvider("openai-compatible", "Custom endpoint", "http://localhost:8000/v1"));
|
||||
const [probe, setProbe] = useState("");
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
<section>
|
||||
<h2>Providers</h2>
|
||||
<div className="provider-list">
|
||||
{providers.map((provider) => (
|
||||
<article key={provider.id}>
|
||||
<strong>{provider.name}</strong>
|
||||
<span>{provider.baseUrl}</span>
|
||||
<button onClick={async () => {
|
||||
const result = await coworkerApi.probeProvider(provider.id);
|
||||
setProbe(`${result.message} ${result.models.join(", ")}`);
|
||||
}}>Test</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Add endpoint</h2>
|
||||
<input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} />
|
||||
<input value={draft.baseUrl} onChange={(event) => setDraft({ ...draft, baseUrl: event.target.value })} />
|
||||
<button className="primary" onClick={async () => {
|
||||
try {
|
||||
onState(await coworkerApi.saveProvider(draft));
|
||||
setDraft(createProvider("openai-compatible", "Custom endpoint", "http://localhost:8000/v1"));
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Provider save failed.");
|
||||
}
|
||||
}}>Save provider</button>
|
||||
<p className="muted">{probe || "Ollama and LM Studio defaults are created on first launch."}</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrainView({ items, onState, onError }: { items: BrainItem[]; onState: (state: AppState) => void; onError: (value: string) => void }) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [kind, setKind] = useState<"memory" | "skill">("memory");
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
<section>
|
||||
<h2>Brain</h2>
|
||||
<div className="brain-list">
|
||||
{items.map((item) => (
|
||||
<article key={item.id}>
|
||||
<span>{displayBrainItem(item)}</span>
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.body}</p>
|
||||
<button onClick={async () => onState(await coworkerApi.removeBrain(item.id))}>Remove</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Add Brain item</h2>
|
||||
<div className="segmented">
|
||||
<button className={kind === "memory" ? "active" : ""} onClick={() => setKind("memory")}>Memory</button>
|
||||
<button className={kind === "skill" ? "active" : ""} onClick={() => setKind("skill")}>Skill</button>
|
||||
</div>
|
||||
<input value={title} placeholder="Title" onChange={(event) => setTitle(event.target.value)} />
|
||||
<textarea value={body} placeholder="What should Coworker remember or know how to do?" onChange={(event) => setBody(event.target.value)} />
|
||||
<button className="primary" onClick={async () => {
|
||||
try {
|
||||
onState(await coworkerApi.addBrain({ kind, scope: "global", title, body, pinned: kind === "skill" }));
|
||||
setTitle("");
|
||||
setBody("");
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Brain save failed.");
|
||||
}
|
||||
}}>Save</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsView({ state, onState, onError }: { state: AppState; onState: (state: AppState) => void; onError: (value: string) => void }) {
|
||||
const [settings, setSettings] = useState(state.webAccess);
|
||||
const [pairing, setPairing] = useState("");
|
||||
const url = useMemo(() => `http://${settings.bindHost === "0.0.0.0" ? "localhost" : settings.bindHost}:${settings.port}`, [settings]);
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
<section>
|
||||
<h2>Web access</h2>
|
||||
<label className="switch"><input type="checkbox" checked={settings.enabled} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /> Enable Web/PWA access</label>
|
||||
<label>Bind host</label>
|
||||
<select value={settings.bindHost} onChange={(event) => setSettings({ ...settings, bindHost: event.target.value as "127.0.0.1" | "0.0.0.0" })}>
|
||||
<option value="127.0.0.1">Localhost only</option>
|
||||
<option value="0.0.0.0">LAN / Tailscale</option>
|
||||
</select>
|
||||
<label>Port</label>
|
||||
<input type="number" value={settings.port} min={1024} max={65535} onChange={(event) => setSettings({ ...settings, port: Number(event.target.value) })} />
|
||||
<button className="primary" onClick={async () => {
|
||||
try {
|
||||
onState(await coworkerApi.saveWebSettings(settings));
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : "Web access update failed.");
|
||||
}
|
||||
}}>Apply</button>
|
||||
<p className="muted">Open {url} on iPhone or iPad after enabling LAN access.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Pair device</h2>
|
||||
<button onClick={async () => {
|
||||
const challenge = await coworkerApi.createPairing();
|
||||
setPairing(`${challenge.code} expires ${new Date(challenge.expiresAt).toLocaleTimeString()}`);
|
||||
}}>Create pairing code</button>
|
||||
<div className="pairing-code">{pairing || "No active code"}</div>
|
||||
<p className="muted">The mobile webapp stores a scoped device session after pairing.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function label(view: View) {
|
||||
return ({ agent: "Agent", models: "Models", brain: "Brain", files: "Files", settings: "Settings" })[view];
|
||||
}
|
||||
|
||||
function subtitle(view: View, workspace?: WorkspaceState) {
|
||||
if (view === "agent") return workspace ? `Working in ${workspace.name}` : "Open a project to start.";
|
||||
if (view === "models") return "Connect Ollama, LM Studio, or OpenAI-compatible endpoints.";
|
||||
if (view === "brain") return "Persistent memory, project knowledge, and reusable skills.";
|
||||
if (view === "files") return workspace ? workspace.rootPath : "Open a project to inspect files.";
|
||||
return "Desktop, web access, pairing, and release-safe defaults.";
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
461
apps/desktop/src/styles.css
Normal file
461
apps/desktop/src/styles.css
Normal file
@@ -0,0 +1,461 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #111317;
|
||||
color: #f4f6f8;
|
||||
--bg: #111317;
|
||||
--panel: #181b20;
|
||||
--panel-2: #20242b;
|
||||
--line: #303640;
|
||||
--muted: #9ba6b3;
|
||||
--text: #f4f6f8;
|
||||
--accent: #7dd3fc;
|
||||
--accent-2: #84cc16;
|
||||
--danger: #fb7185;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #0f1115;
|
||||
color: var(--text);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
background: #0e1014;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #15202a;
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand strong,
|
||||
.brand span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand span,
|
||||
.muted,
|
||||
.status span,
|
||||
.message span,
|
||||
dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nav button {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav button.active,
|
||||
.segmented button.active {
|
||||
border-color: var(--accent);
|
||||
background: #142333;
|
||||
}
|
||||
|
||||
.status {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
background: var(--panel);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.status strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
border-color: #24506a;
|
||||
background: #123047;
|
||||
color: #e6f7ff;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
min-height: 86px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 18px 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 4px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 15px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
margin-bottom: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ok,
|
||||
.error {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 7px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.agent-grid,
|
||||
.settings-grid,
|
||||
.split {
|
||||
min-height: 0;
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.agent-grid {
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
}
|
||||
|
||||
.settings-grid,
|
||||
.split {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
|
||||
}
|
||||
|
||||
.conversation,
|
||||
.inspector,
|
||||
.settings-grid section,
|
||||
.list-panel,
|
||||
.preview {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.conversation {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
max-width: 520px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #12161c;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
background: #151d20;
|
||||
}
|
||||
|
||||
.message pre,
|
||||
.preview {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
font-family: "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.composer {
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
min-height: 58px;
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.inspector,
|
||||
.settings-grid section {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: 86px minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.provider-list,
|
||||
.brain-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.provider-list article,
|
||||
.brain-list article {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
background: #12161c;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-list span,
|
||||
.brain-list span {
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.brain-list p {
|
||||
margin-bottom: 0;
|
||||
color: #d6dbe1;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-grid section > * + * {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.pairing-code {
|
||||
min-height: 72px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #101319;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 20px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.list-panel button {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-panel span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
padding: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-row: 2;
|
||||
border-right: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px max(8px, env(safe-area-inset-left)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.brand,
|
||||
.status,
|
||||
.sidebar .primary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nav button {
|
||||
min-height: 48px;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
min-height: 72px;
|
||||
padding: calc(12px + env(safe-area-inset-top)) 14px 12px;
|
||||
}
|
||||
|
||||
.topbar p,
|
||||
.ok,
|
||||
.error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.agent-grid,
|
||||
.settings-grid,
|
||||
.split {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.inspector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.composer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-grid,
|
||||
.split {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user