@wenathlan/devthink 1.1.11 → 1.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,18 @@
1
1
  # DevThink release notes
2
+ ## 1.1.12 — Page domains and tabbed interaction
3
+
4
+ ### Added
5
+
6
+ | Area | Addition |
7
+ | --- | --- |
8
+ | Page-domain topology | The web workbench now keeps each route anchor and its local visual logic in its named domain, with `home`, `providers`, `projects`, `routes`, `usage`, `notfound`, `app` and `primitives` replacing generic `components` and `pages` directories. |
9
+ | Canonical local database | DevThink now uses `~/.config/devthink/devthink.db` as the canonical SQLite path and safely seeds it from the legacy `devthink.sqlite` file when needed. |
10
+ | Shared workbench preferences | Theme, rail mode and interface zoom are stored in the CLI-owned database, exposed only through authenticated local gateway endpoints, and configurable with `devthink config set`. |
11
+ | Interactive Settings parity | The web Settings panel and Ink `/settings` view can both inspect and change shared presentation preferences without receiving provider credentials. |
12
+ | Tab-first workspace frame | The workbench promotes the browser-like tab row as its primary navigation and permits an `always`, `auto` or `off` local navigation rail. |
13
+ | Consolidated identity | The entry screen centers the product wordmark over the canonical ANSI mark while the workspace keeps the rail as its single optional identity surface. |
14
+ | Mutation boundary | JSON-bearing gateway mutations now require an explicit JSON content type; pairing, bearer authorization and local-only provider credential boundaries remain intact. |
15
+
2
16
  ## 1.1.11 — Web-CLI parity and visual renewal
3
17
 
4
18
  ### Added
package/build.ts CHANGED
@@ -6,7 +6,7 @@ type BunRuntime = {
6
6
  };
7
7
 
8
8
  function bunRuntime(): BunRuntime {
9
- const runtime = (globalThis as { Bun?: BunRuntime }).Bun;
9
+ const runtime = (globalThis as unknown as { Bun?: BunRuntime }).Bun;
10
10
  if (!runtime) throw new Error("Bun is required to compile DevThink binaries.");
11
11
  return runtime;
12
12
  }
package/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { homedir, platform } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
@@ -43,6 +43,7 @@ export type DevThinkPaths = {
43
43
  sessions: string;
44
44
  workspaces: string;
45
45
  database: string;
46
+ legacyDatabase: string;
46
47
  memory: string;
47
48
  logs: string;
48
49
  };
@@ -56,11 +57,14 @@ function resolveHome(): string {
56
57
  }
57
58
 
58
59
  export function resolvePaths(root = resolveHome()): DevThinkPaths {
59
- return { home: root, config: join(root, "devthink.json"), auth: join(root, "auth.json"), identity: join(root, "identity.json"), pairings: join(root, "pairings.json"), legacyConfig: join(root, "config.json"), sessions: join(root, "sessions"), workspaces: join(root, "workspaces"), database: join(root, "devthink.sqlite"), memory: join(root, "memory"), logs: join(root, "logs") };
60
+ return { home: root, config: join(root, "devthink.json"), auth: join(root, "auth.json"), identity: join(root, "identity.json"), pairings: join(root, "pairings.json"), legacyConfig: join(root, "config.json"), sessions: join(root, "sessions"), workspaces: join(root, "workspaces"), database: join(root, "devthink.db"), legacyDatabase: join(root, "devthink.sqlite"), memory: join(root, "memory"), logs: join(root, "logs") };
60
61
  }
61
62
 
62
63
  export function ensurePaths(paths = resolvePaths()): DevThinkPaths {
63
64
  for (const path of [paths.home, paths.sessions, paths.workspaces, paths.memory, paths.logs]) mkdirSync(path, { recursive: true });
65
+ if (!existsSync(paths.database) && existsSync(paths.legacyDatabase)) {
66
+ try { copyFileSync(paths.legacyDatabase, paths.database); } catch { /* The JSON session records remain the safe compatibility fallback. */ }
67
+ }
64
68
  return paths;
65
69
  }
66
70
 
@@ -197,7 +201,9 @@ export function resolveCredential(provider: string, config: DevThinkConfig, path
197
201
  }
198
202
  const property = `${provider}ApiKey` as keyof DevThinkConfig;
199
203
  const nested = config.providers?.[provider];
200
- return nested?.auth?.value || nested?.apiKey || config[property] || (provider === (config.activeProvider || config.provider) ? config.apiKey : undefined) || process.env.DEVTHINK_API_KEY?.trim();
204
+ const legacy = config[property];
205
+ const legacyValue = typeof legacy === "string" ? legacy : undefined;
206
+ return nested?.auth?.value || nested?.apiKey || legacyValue || (provider === (config.activeProvider || config.provider) ? config.apiKey : undefined) || process.env.DEVTHINK_API_KEY?.trim();
201
207
  }
202
208
 
203
209
  export function redactValue(value: unknown): unknown {
package/devthink.ts CHANGED
@@ -9,6 +9,7 @@ import { listModes, modePrompt, resolveMode } from "./modes.ts";
9
9
  import { listModels, listProviders, streamChat } from "./providers.ts";
10
10
  import { appendMessage, createSession, exportSession, listSessions, loadSession, type Session } from "./session.ts";
11
11
  import { createPairing, createPairingLink, getIdentity, pairingStatus, revokeBrowserSessions } from "./identity.ts";
12
+ import { readPreferences, savePreference } from "./storage.ts";
12
13
  import type { ChatEvent } from "./stream.ts";
13
14
  import { banner, box, colors, formatConfig, formatEvent, statusBar } from "./ui.ts";
14
15
  import { startServer } from "./server.ts";
@@ -67,6 +68,8 @@ function printHelp(): void {
67
68
  " models --provider <id> List provider models",
68
69
  " modes List the 20 operational modes",
69
70
  " config [key] [value] Read or write local configuration",
71
+ " config preferences Show shared workbench preferences",
72
+ " config set <key> <value> Set theme, railMode, or interfaceZoom",
70
73
  " auth login <provider> Save a user-provided official credential",
71
74
  " auth status Show redacted configured credential sources",
72
75
  " auth clear <provider> Remove a stored provider credential",
@@ -151,7 +154,18 @@ async function runChat(prompt: string, parsed: ParsedArgs, runtime: ReturnType<t
151
154
  }
152
155
 
153
156
  async function handleConfig(parsed: ParsedArgs, runtime: ReturnType<typeof loadRuntime>): Promise<void> {
154
- const key = parsed.positional[1];
157
+ const action = parsed.positional[1];
158
+ if (action === "preferences") return console.log(JSON.stringify(Object.fromEntries(Object.entries(readPreferences(runtime.paths)).map(([key, preference]) => [key, preference.value])), null, 2));
159
+ if (action === "set") {
160
+ const key = parsed.positional[2];
161
+ const value = parsed.positional[3];
162
+ if (!key || !value) throw new Error("Usage: devthink config set <theme|railMode|interfaceZoom> <value>");
163
+ const valid = (key === "theme" && ["dark", "light"].includes(value)) || (key === "railMode" && ["always", "auto", "off"].includes(value)) || (key === "interfaceZoom" && /^(?:[8-9][0-9]|1[0-4][0-9]|150)$/.test(value));
164
+ if (!valid) throw new Error("Supported values: theme dark|light; railMode always|auto|off; interfaceZoom 80-150.");
165
+ savePreference(runtime.paths, key, value);
166
+ return console.log(`${key} updated.`);
167
+ }
168
+ const key = action;
155
169
  const raw = parsed.positional[2];
156
170
  if (!key) return console.log(formatConfig(redactConfig(runtime.config)));
157
171
  if (raw === undefined) return console.log(`${key} = ${JSON.stringify(getConfigValue(runtime.config, key))}`);
@@ -264,7 +278,7 @@ function handleUsage(runtime: ReturnType<typeof loadRuntime>): void {
264
278
  }
265
279
 
266
280
  function handleRoutes(): void {
267
- console.log(["web: /", "web: /providers", "web: /projects", "web: /routes", "web: /usage", "web: /w/:workspaceId/s/:sessionId/t/:tabId/:sectionId", "gateway: GET /health", "gateway: GET /providers", "gateway: GET /workspaces", "gateway: GET /usage", "gateway: POST /sessions", "gateway: POST /chat"].join("\n"));
281
+ console.log(["web: /", "web: /providers", "web: /projects", "web: /routes", "web: /usage", "web: /w/:workspaceId/s/:sessionId/t/:tabId/:sectionId", "gateway: GET /health", "gateway: GET /providers", "gateway: GET /workspaces", "gateway: GET /usage", "gateway: GET /preferences", "gateway: PATCH /preferences", "gateway: POST /sessions", "gateway: POST /chat"].join("\n"));
268
282
  }
269
283
 
270
284
  async function interactive(runtime: ReturnType<typeof loadRuntime>): Promise<void> {
package/memory.ts CHANGED
@@ -80,7 +80,7 @@ export function createMemoryStore(paths: DevThinkPaths) {
80
80
  return undefined;
81
81
  },
82
82
  list(layer?: MemoryLayer, sessionId?: string): MemoryEntry[] {
83
- const layers = layer ? [layer] : ["session", "project", "global"];
83
+ const layers: MemoryLayer[] = layer ? [layer] : ["session", "project", "global"];
84
84
  return layers.flatMap((item) => readEntries(memoryFile(paths, item, sessionId), item));
85
85
  },
86
86
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenathlan/devthink",
3
- "version": "1.1.11",
3
+ "version": "1.1.12",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/wenathlan/devthink"
package/server.ts CHANGED
@@ -5,6 +5,7 @@ import { listModels, listProviders, streamChat, type ChatMessage } from "./provi
5
5
  import { ensurePaths, resolvePaths, saveConfig, type DevThinkConfig, type DevThinkPaths } from "./config.ts";
6
6
  import { consumePairing, getIdentity, pairingStatus, revokeBrowserSessions, verifyBrowserSession } from "./identity.ts";
7
7
  import { appendMessage, createSession, createTab, listSessions, loadSession, loadWorkspace, updateTab, type SessionSection } from "./session.ts";
8
+ import { readPreferences, savePreference } from "./storage.ts";
8
9
 
9
10
  export type ServerOptions = {
10
11
  port?: number;
@@ -47,6 +48,12 @@ async function readBody(request: IncomingMessage): Promise<Record<string, unknow
47
48
  return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
48
49
  }
49
50
 
51
+ function acceptsJson(request: IncomingMessage): boolean {
52
+ return request.headers["content-type"]?.toLowerCase().includes("application/json") === true;
53
+ }
54
+
55
+ const browserPreferenceKeys = new Set(["theme", "railMode", "interfaceZoom"]);
56
+
50
57
  function messagesFromBody(value: unknown): ChatMessage[] {
51
58
  if (!Array.isArray(value)) throw new Error("messages must be an array.");
52
59
  return value.map((item) => {
@@ -107,9 +114,12 @@ async function route(request: IncomingMessage, response: ServerResponse, config:
107
114
  response.setHeader("access-control-allow-headers", "authorization, content-type");
108
115
  response.setHeader("vary", "Origin");
109
116
  response.writeHead(204);
110
- return response.end();
117
+ response.end();
118
+ return;
111
119
  }
112
120
  if (request.headers.origin && !origin) return writeJson(response, 403, { error: "Origin is not allowed by web.allowedOrigins." });
121
+ const mutationWithBody = ["POST", "PATCH", "PUT"].includes(request.method || "") && url.pathname !== "/pairings/revoke";
122
+ if (mutationWithBody && !acceptsJson(request)) return writeJson(response, 415, { error: "JSON content-type is required for mutation requests." }, origin);
113
123
  if (request.method === "GET" && url.pathname === "/health") return writeJson(response, 200, { status: "ok", service: "devthink" }, origin);
114
124
  if (request.method === "GET" && url.pathname === "/providers") return writeJson(response, 200, listProviders().map(({ id, protocol, env }) => ({ id, protocol, env })), origin);
115
125
  if (request.method === "GET" && url.pathname === "/models") {
@@ -144,6 +154,16 @@ async function route(request: IncomingMessage, response: ServerResponse, config:
144
154
  const messageCount = sessions.reduce((total, session) => total + session.messages.length, 0);
145
155
  return writeJson(response, 200, { workspaces: new Set(sessions.map((session) => session.workspaceId)).size, sessions: sessions.length, tabs: tabCount, messages: messageCount, providers: listProviders().length, paired: pairingStatus(paths).activeSessions > 0 }, origin);
146
156
  }
157
+ if (request.method === "GET" && url.pathname === "/preferences") {
158
+ return writeJson(response, 200, { preferences: Object.fromEntries(Object.entries(readPreferences(paths)).map(([key, preference]) => [key, preference.value])) }, origin);
159
+ }
160
+ if (request.method === "PATCH" && url.pathname === "/preferences") {
161
+ const body = await readBody(request);
162
+ const key = typeof body.key === "string" ? body.key : "";
163
+ const value = typeof body.value === "string" ? body.value : "";
164
+ if (!browserPreferenceKeys.has(key) || !value || value.length > 32) return writeJson(response, 400, { error: "Preference is not supported." }, origin);
165
+ return writeJson(response, 200, { preference: savePreference(paths, key, value) }, origin);
166
+ }
147
167
  if (request.method === "PATCH" && url.pathname === "/providers/active") {
148
168
  const body = await readBody(request);
149
169
  const provider = typeof body.provider === "string" ? body.provider : "";
package/storage.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** DevThink local store: mirrors credential-free workbench records into a physical SQLite database. */
2
2
  import type { DevThinkPaths } from "./config.ts";
3
3
 
4
- type Statement = { run: (...values: unknown[]) => unknown };
4
+ type Statement = { run: (...values: unknown[]) => unknown; all: (...values: unknown[]) => unknown[] };
5
5
  type Database = { exec: (sql: string) => unknown; prepare: (sql: string) => Statement; close: () => void };
6
6
  type DatabaseConstructor = new (path: string) => Database;
7
7
 
@@ -12,6 +12,7 @@ export type StoredWorkspace = { id: string; title: string; createdAt: string; up
12
12
  export type StoredTab = { id: string; sessionId: string; workspaceId: string; label: string; provider?: string; sectionId: string; createdAt: string; updatedAt: string };
13
13
  export type StoredMessage = { id: string; sessionId: string; workspaceId: string; tabId: string; sectionId: string; role: string; content: string; createdAt: string };
14
14
  export type StoredSession = { id: string; workspaceId: string; title: string; mode: string; model?: string; provider?: string; activeTabId: string; createdAt: string; updatedAt: string; tabs: StoredTab[]; messages: StoredMessage[] };
15
+ export type StoredPreference = { key: string; value: string; updatedAt: string };
15
16
 
16
17
  function initialize(database: Database): void {
17
18
  database.exec(`
@@ -21,6 +22,7 @@ function initialize(database: Database): void {
21
22
  CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, title TEXT NOT NULL, mode TEXT NOT NULL, model TEXT, provider TEXT, active_tab_id TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
22
23
  CREATE TABLE IF NOT EXISTS tabs (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, workspace_id TEXT NOT NULL, label TEXT NOT NULL, provider TEXT, section_id TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
23
24
  CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, workspace_id TEXT NOT NULL, tab_id TEXT NOT NULL, section_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL);
25
+ CREATE TABLE IF NOT EXISTS preferences (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL);
24
26
  CREATE INDEX IF NOT EXISTS sessions_workspace_updated ON sessions(workspace_id, updated_at DESC);
25
27
  CREATE INDEX IF NOT EXISTS tabs_session_updated ON tabs(session_id, updated_at DESC);
26
28
  CREATE INDEX IF NOT EXISTS messages_session_created ON messages(session_id, created_at ASC);
@@ -49,3 +51,32 @@ export function mirrorSession(paths: DevThinkPaths, workspace: StoredWorkspace,
49
51
  database?.close();
50
52
  }
51
53
  }
54
+
55
+ /** Reads non-sensitive workbench preferences from the CLI-owned local database. */
56
+ export function readPreferences(paths: DevThinkPaths): Record<string, StoredPreference> {
57
+ let database: Database | undefined;
58
+ try {
59
+ database = new DatabaseDriver(paths.database);
60
+ initialize(database);
61
+ const rows = database.prepare("SELECT key, value, updated_at AS updatedAt FROM preferences ORDER BY key ASC").all() as StoredPreference[];
62
+ return Object.fromEntries(rows.map((row) => [row.key, row]));
63
+ } catch {
64
+ return {};
65
+ } finally {
66
+ database?.close();
67
+ }
68
+ }
69
+
70
+ /** Persists an allowlisted interface preference without mixing it with provider credentials. */
71
+ export function savePreference(paths: DevThinkPaths, key: string, value: string): StoredPreference {
72
+ const preference = { key, value, updatedAt: new Date().toISOString() };
73
+ let database: Database | undefined;
74
+ try {
75
+ database = new DatabaseDriver(paths.database);
76
+ initialize(database);
77
+ database.prepare("INSERT INTO preferences (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at").run(preference.key, preference.value, preference.updatedAt);
78
+ } finally {
79
+ database?.close();
80
+ }
81
+ return preference;
82
+ }
package/terminal-ui.tsx CHANGED
@@ -1,8 +1,9 @@
1
- /** Design: DevThink v1.1.11 — Ink shares local workspace destinations, tab semantics and command language with the React web workbench. */
1
+ /** Design: DevThink v1.1.12 — Ink shares local workspace destinations, tab semantics, settings and command language with the React web workbench. */
2
2
  import { Box, Text, render, useApp, useInput, useWindowSize } from "ink";
3
3
  import { useMemo, useState } from "react";
4
4
  import { listProviders } from "./providers.ts";
5
5
  import { listSessions, type Session } from "./session.ts";
6
+ import { readPreferences, savePreference } from "./storage.ts";
6
7
  import type { DevThinkConfig, DevThinkPaths } from "./config.ts";
7
8
  import type { ChatEvent } from "./stream.ts";
8
9
  import { isWorkspaceDestination, workspaceDestination, workspaceDestinations, type WorkspaceDestination } from "./workspace.ts";
@@ -41,8 +42,11 @@ function viewRows(view: WorkspaceDestination, runtime: Runtime, session: Session
41
42
  const sessions = listSessions(runtime.paths);
42
43
  return [`workspaces ${new Set(sessions.map((item) => item.workspaceId)).size}`, `sessions ${sessions.length}`, `tabs ${sessions.reduce((total, item) => total + item.tabs.length, 0)}`, `messages ${sessions.reduce((total, item) => total + item.messages.length, 0)}`];
43
44
  }
44
- if (view === "routes") return ["GET /health", "GET /providers", "GET /workspaces", "GET /usage", "POST /sessions", "POST /chat"];
45
- if (view === "settings") return [`provider ${runtime.config.activeProvider || runtime.config.provider || "not configured"}`, "credentials remain in ~/.config/devthink/auth.json", "pair create emits a one-time local browser invitation"];
45
+ if (view === "routes") return ["GET /health", "GET /providers", "GET /workspaces", "GET /usage", "GET /preferences", "PATCH /preferences", "POST /sessions", "POST /chat"];
46
+ if (view === "settings") {
47
+ const preferences = Object.fromEntries(Object.entries(readPreferences(runtime.paths)).map(([key, preference]) => [key, preference.value]));
48
+ return [`theme ${preferences.theme || "dark"}`, `railMode ${preferences.railMode || "auto"}`, `interfaceZoom ${preferences.interfaceZoom || "100"}`, "edit: /settings set theme dark|light", "edit: /settings set railMode always|auto|off", "edit: /settings set interfaceZoom 80-150", "credentials remain in ~/.config/devthink/auth.json"];
49
+ }
46
50
  if (session) return [`workspace ${session.workspaceId}`, `session ${session.id}`, `tab ${session.activeTabId}`];
47
51
  return ["Describe the next feature, bug or piece of work below."];
48
52
  }
@@ -57,12 +61,13 @@ function TerminalWorkspace({ runtime, execute, version }: { runtime: Runtime; ex
57
61
  const [session, setSession] = useState<Session>();
58
62
  const [draft, setDraft] = useState("");
59
63
  const [pending, setPending] = useState(false);
64
+ const [preferenceRevision, setPreferenceRevision] = useState(0);
60
65
  const provider = runtime.config.activeProvider || runtime.config.provider || "local";
61
66
  const model = runtime.config.activeModel || runtime.config.model || "not configured";
62
67
  const compact = columns < 100;
63
68
  const terminalWidth = Math.max(38, columns - (compact ? 2 : 6));
64
69
  const tabLine = compact ? workspaceDestinations.map((destination) => destination.glyph).join(" ") : workspaceDestinations.map((destination) => `${destination.id === view ? "[" : ""}${destination.glyph} ${destination.label}${destination.id === view ? "]" : ""}`).join(" ");
65
- const rows = useMemo(() => viewRows(view, runtime, session), [runtime, session, view]);
70
+ const rows = useMemo(() => viewRows(view, runtime, session), [preferenceRevision, runtime, session, view]);
66
71
 
67
72
  function openView(next: WorkspaceDestination) {
68
73
  setView(next);
@@ -84,7 +89,16 @@ function TerminalWorkspace({ runtime, execute, version }: { runtime: Runtime; ex
84
89
  const viewMatch = prompt.match(/^\/(?:view\s+)?(chat|history|projects|providers|usage|routes|settings)$/);
85
90
  if (viewMatch && isWorkspaceDestination(viewMatch[1])) { openView(viewMatch[1]); setDraft(""); return; }
86
91
  if (prompt === "/sessions") { openView("history"); setDraft(""); return; }
87
- if (prompt === "/help") { setEntries((current) => [...current, { role: "devthink", title: "command reference", body: "/view chat|history|projects|providers|usage|routes|settings /new /clear /models /modes /sessions /exit" }]); setDraft(""); return; }
92
+ const preferenceMatch = prompt.match(/^\/(?:settings\s+)?set\s+(theme|railMode|interfaceZoom)\s+(\S+)$/);
93
+ if (preferenceMatch) {
94
+ const [, key, value] = preferenceMatch;
95
+ const valid = (key === "theme" && ["dark", "light"].includes(value)) || (key === "railMode" && ["always", "auto", "off"].includes(value)) || (key === "interfaceZoom" && /^(?:[8-9][0-9]|1[0-4][0-9]|150)$/.test(value));
96
+ if (!valid) setEntries((current) => [...current, { role: "error", title: "settings", body: "Use theme dark|light, railMode always|auto|off, or interfaceZoom 80-150." }]);
97
+ else { savePreference(runtime.paths, key, value); setPreferenceRevision((current) => current + 1); setView("settings"); setEntries((current) => [...current, { role: "devthink", title: "settings", body: `${key} updated to ${value}.` }]); }
98
+ setDraft("");
99
+ return;
100
+ }
101
+ if (prompt === "/help") { setEntries((current) => [...current, { role: "devthink", title: "command reference", body: "/view chat|history|projects|providers|usage|routes|settings /settings set <key> <value> /new /clear /models /modes /sessions /exit" }]); setDraft(""); return; }
88
102
  setView("chat");
89
103
  setEntries((current) => [...current, { role: "user", title: active, body: prompt }, { role: "devthink", title: "connecting", body: "waiting for local gateway events…" }]);
90
104
  setDraft("");