@geminixiang/mama 0.2.0-beta.6 → 0.2.0-beta.8

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.
Files changed (173) hide show
  1. package/README.md +20 -14
  2. package/dist/adapter.d.ts +5 -2
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/bot.d.ts +1 -0
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +29 -9
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts +1 -1
  10. package/dist/adapters/discord/context.d.ts.map +1 -1
  11. package/dist/adapters/discord/context.js +1 -2
  12. package/dist/adapters/discord/context.js.map +1 -1
  13. package/dist/adapters/slack/bot.d.ts +8 -0
  14. package/dist/adapters/slack/bot.d.ts.map +1 -1
  15. package/dist/adapters/slack/bot.js +177 -11
  16. package/dist/adapters/slack/bot.js.map +1 -1
  17. package/dist/adapters/slack/branch-manager.d.ts +1 -0
  18. package/dist/adapters/slack/branch-manager.d.ts.map +1 -1
  19. package/dist/adapters/slack/branch-manager.js +9 -8
  20. package/dist/adapters/slack/branch-manager.js.map +1 -1
  21. package/dist/adapters/slack/context.d.ts +1 -1
  22. package/dist/adapters/slack/context.d.ts.map +1 -1
  23. package/dist/adapters/slack/context.js +10 -8
  24. package/dist/adapters/slack/context.js.map +1 -1
  25. package/dist/adapters/slack/tools/attach.d.ts +1 -1
  26. package/dist/adapters/slack/tools/attach.d.ts.map +1 -1
  27. package/dist/adapters/slack/tools/attach.js.map +1 -1
  28. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  29. package/dist/adapters/telegram/bot.js +33 -2
  30. package/dist/adapters/telegram/bot.js.map +1 -1
  31. package/dist/agent.d.ts +1 -2
  32. package/dist/agent.d.ts.map +1 -1
  33. package/dist/agent.js +507 -422
  34. package/dist/agent.js.map +1 -1
  35. package/dist/commands/index.d.ts.map +1 -1
  36. package/dist/commands/index.js +2 -0
  37. package/dist/commands/index.js.map +1 -1
  38. package/dist/commands/login.d.ts.map +1 -1
  39. package/dist/commands/login.js +41 -2
  40. package/dist/commands/login.js.map +1 -1
  41. package/dist/commands/model.d.ts +1 -1
  42. package/dist/commands/model.d.ts.map +1 -1
  43. package/dist/commands/model.js +25 -7
  44. package/dist/commands/model.js.map +1 -1
  45. package/dist/commands/new.d.ts.map +1 -1
  46. package/dist/commands/new.js +1 -1
  47. package/dist/commands/new.js.map +1 -1
  48. package/dist/commands/sandbox.d.ts +10 -0
  49. package/dist/commands/sandbox.d.ts.map +1 -0
  50. package/dist/commands/sandbox.js +88 -0
  51. package/dist/commands/sandbox.js.map +1 -0
  52. package/dist/commands/session-view.d.ts.map +1 -1
  53. package/dist/commands/session-view.js +34 -10
  54. package/dist/commands/session-view.js.map +1 -1
  55. package/dist/commands/types.d.ts +1 -3
  56. package/dist/commands/types.d.ts.map +1 -1
  57. package/dist/commands/types.js.map +1 -1
  58. package/dist/commands/utils.d.ts +3 -0
  59. package/dist/commands/utils.d.ts.map +1 -1
  60. package/dist/commands/utils.js +5 -0
  61. package/dist/commands/utils.js.map +1 -1
  62. package/dist/config.d.ts +7 -1
  63. package/dist/config.d.ts.map +1 -1
  64. package/dist/config.js +64 -23
  65. package/dist/config.js.map +1 -1
  66. package/dist/context.d.ts +2 -44
  67. package/dist/context.d.ts.map +1 -1
  68. package/dist/context.js +7 -210
  69. package/dist/context.js.map +1 -1
  70. package/dist/events.d.ts.map +1 -1
  71. package/dist/events.js +15 -14
  72. package/dist/events.js.map +1 -1
  73. package/dist/execution-resolver.d.ts +3 -2
  74. package/dist/execution-resolver.d.ts.map +1 -1
  75. package/dist/execution-resolver.js +40 -7
  76. package/dist/execution-resolver.js.map +1 -1
  77. package/dist/file-guards.d.ts +6 -0
  78. package/dist/file-guards.d.ts.map +1 -0
  79. package/dist/file-guards.js +48 -0
  80. package/dist/file-guards.js.map +1 -0
  81. package/dist/log.d.ts +1 -5
  82. package/dist/log.d.ts.map +1 -1
  83. package/dist/log.js +13 -38
  84. package/dist/log.js.map +1 -1
  85. package/dist/login/index.d.ts +14 -2
  86. package/dist/login/index.d.ts.map +1 -1
  87. package/dist/login/index.js +40 -13
  88. package/dist/login/index.js.map +1 -1
  89. package/dist/login/portal.d.ts +2 -1
  90. package/dist/login/portal.d.ts.map +1 -1
  91. package/dist/login/portal.js +12 -12
  92. package/dist/login/portal.js.map +1 -1
  93. package/dist/main.d.ts.map +1 -1
  94. package/dist/main.js +33 -28
  95. package/dist/main.js.map +1 -1
  96. package/dist/provisioner.d.ts +12 -2
  97. package/dist/provisioner.d.ts.map +1 -1
  98. package/dist/provisioner.js +43 -14
  99. package/dist/provisioner.js.map +1 -1
  100. package/dist/runtime/conversation-orchestrator.d.ts +42 -0
  101. package/dist/runtime/conversation-orchestrator.d.ts.map +1 -0
  102. package/dist/runtime/conversation-orchestrator.js +150 -0
  103. package/dist/runtime/conversation-orchestrator.js.map +1 -0
  104. package/dist/runtime/session-runtime.d.ts +1 -1
  105. package/dist/runtime/session-runtime.d.ts.map +1 -1
  106. package/dist/runtime/session-runtime.js +49 -148
  107. package/dist/runtime/session-runtime.js.map +1 -1
  108. package/dist/sandbox/cloudflare.d.ts.map +1 -1
  109. package/dist/sandbox/cloudflare.js +2 -2
  110. package/dist/sandbox/cloudflare.js.map +1 -1
  111. package/dist/sandbox/container.d.ts.map +1 -1
  112. package/dist/sandbox/container.js +1 -1
  113. package/dist/sandbox/container.js.map +1 -1
  114. package/dist/sandbox/index.d.ts.map +1 -1
  115. package/dist/sandbox/index.js +4 -4
  116. package/dist/sandbox/index.js.map +1 -1
  117. package/dist/sentry.d.ts +1 -1
  118. package/dist/sentry.d.ts.map +1 -1
  119. package/dist/sentry.js +2 -2
  120. package/dist/sentry.js.map +1 -1
  121. package/dist/session-store.d.ts +2 -1
  122. package/dist/session-store.d.ts.map +1 -1
  123. package/dist/session-store.js +19 -15
  124. package/dist/session-store.js.map +1 -1
  125. package/dist/session-view/portal.d.ts +6 -1
  126. package/dist/session-view/portal.d.ts.map +1 -1
  127. package/dist/session-view/portal.js +829 -71
  128. package/dist/session-view/portal.js.map +1 -1
  129. package/dist/session-view/service.d.ts.map +1 -1
  130. package/dist/session-view/service.js +5 -4
  131. package/dist/session-view/service.js.map +1 -1
  132. package/dist/session-view/store.d.ts +2 -1
  133. package/dist/session-view/store.d.ts.map +1 -1
  134. package/dist/session-view/store.js +2 -1
  135. package/dist/session-view/store.js.map +1 -1
  136. package/dist/store.d.ts.map +1 -1
  137. package/dist/store.js +7 -13
  138. package/dist/store.js.map +1 -1
  139. package/dist/tool-diagnostics.d.ts +2 -0
  140. package/dist/tool-diagnostics.d.ts.map +1 -0
  141. package/dist/tool-diagnostics.js +7 -0
  142. package/dist/tool-diagnostics.js.map +1 -0
  143. package/dist/tools/bash.d.ts +1 -1
  144. package/dist/tools/bash.d.ts.map +1 -1
  145. package/dist/tools/bash.js.map +1 -1
  146. package/dist/tools/edit.d.ts +1 -1
  147. package/dist/tools/edit.d.ts.map +1 -1
  148. package/dist/tools/edit.js.map +1 -1
  149. package/dist/tools/event.d.ts +1 -1
  150. package/dist/tools/event.d.ts.map +1 -1
  151. package/dist/tools/event.js.map +1 -1
  152. package/dist/tools/index.d.ts +1 -1
  153. package/dist/tools/index.d.ts.map +1 -1
  154. package/dist/tools/index.js.map +1 -1
  155. package/dist/tools/read.d.ts +1 -1
  156. package/dist/tools/read.d.ts.map +1 -1
  157. package/dist/tools/read.js.map +1 -1
  158. package/dist/tools/write.d.ts +1 -1
  159. package/dist/tools/write.d.ts.map +1 -1
  160. package/dist/tools/write.js.map +1 -1
  161. package/dist/vault-routing.d.ts +0 -3
  162. package/dist/vault-routing.d.ts.map +1 -1
  163. package/dist/vault-routing.js +0 -24
  164. package/dist/vault-routing.js.map +1 -1
  165. package/dist/vault.d.ts +21 -57
  166. package/dist/vault.d.ts.map +1 -1
  167. package/dist/vault.js +114 -246
  168. package/dist/vault.js.map +1 -1
  169. package/package.json +6 -4
  170. package/dist/bindings.d.ts +0 -45
  171. package/dist/bindings.d.ts.map +0 -1
  172. package/dist/bindings.js +0 -75
  173. package/dist/bindings.js.map +0 -1
@@ -1,6 +1,58 @@
1
+ import { basename } from "path";
2
+ import MarkdownIt from "markdown-it";
1
3
  import * as log from "../log.js";
4
+ import { inferConversationKind } from "../session-policy.js";
2
5
  import { loadSessionViewModel, resolveRequestedSessionFile, } from "./service.js";
3
- export async function handleSessionViewRequest(req, res, url, sessionViewTokenStore) {
6
+ const markdown = new MarkdownIt({
7
+ html: false,
8
+ linkify: true,
9
+ breaks: true,
10
+ });
11
+ const defaultLinkOpen = markdown.renderer.rules.link_open;
12
+ markdown.renderer.rules.link_open = (...args) => {
13
+ const [tokens, idx, options, env, self] = args;
14
+ const token = tokens[idx];
15
+ token.attrSet("target", "_blank");
16
+ token.attrSet("rel", "noreferrer noopener");
17
+ return defaultLinkOpen
18
+ ? defaultLinkOpen(tokens, idx, options, env, self)
19
+ : self.renderToken(tokens, idx, options);
20
+ };
21
+ class SessionViewStreamHub {
22
+ constructor() {
23
+ this.listeners = new Map();
24
+ }
25
+ subscribe(key, listener) {
26
+ const set = this.listeners.get(key) ?? new Set();
27
+ set.add(listener);
28
+ this.listeners.set(key, set);
29
+ return () => {
30
+ const current = this.listeners.get(key);
31
+ if (!current)
32
+ return;
33
+ current.delete(listener);
34
+ if (current.size === 0)
35
+ this.listeners.delete(key);
36
+ };
37
+ }
38
+ publish(key, event) {
39
+ const set = this.listeners.get(key);
40
+ if (!set)
41
+ return;
42
+ for (const listener of set)
43
+ listener(event);
44
+ }
45
+ }
46
+ const sessionViewStreamHub = new SessionViewStreamHub();
47
+ export async function handleSessionViewRequest(req, res, url, sessionViewTokenStore, interactive) {
48
+ if (req.method === "POST" && url.pathname === "/session/message") {
49
+ await handleSessionMessageRequest(req, res, sessionViewTokenStore, interactive);
50
+ return true;
51
+ }
52
+ if (req.method === "GET" && url.pathname === "/session/stream") {
53
+ await handleSessionStreamRequest(req, res, url, sessionViewTokenStore, interactive);
54
+ return true;
55
+ }
4
56
  if (req.method !== "GET" || url.pathname !== "/session") {
5
57
  return false;
6
58
  }
@@ -34,11 +86,13 @@ export async function handleSessionViewRequest(req, res, url, sessionViewTokenSt
34
86
  }
35
87
  try {
36
88
  const model = loadSessionViewModel(targetSessionFile);
89
+ const displayedSessionKey = resolveDisplayedSessionKey(entry, targetSessionFile);
90
+ const isRunning = interactive?.handler.isRunning(displayedSessionKey) ?? false;
37
91
  res.writeHead(200, {
38
92
  "Content-Type": "text/html; charset=utf-8",
39
93
  "Cache-Control": "no-store",
40
94
  });
41
- res.end(renderSessionPage(model, entry.token, entry.expiresAt));
95
+ res.end(renderSessionPage(model, entry.token, entry.expiresAt, isRunning, displayedSessionKey, entry.conversationId));
42
96
  }
43
97
  catch (error) {
44
98
  log.logWarning(`[${entry.conversationId}] Failed to render session ${entry.sessionFile}`, error instanceof Error ? error.message : String(error));
@@ -47,10 +101,26 @@ export async function handleSessionViewRequest(req, res, url, sessionViewTokenSt
47
101
  }
48
102
  return true;
49
103
  }
50
- function renderSessionPage(model, token, expiresAt) {
51
- const items = model.items.length > 0
52
- ? model.items.map((item) => renderItem(item, token)).join("\n")
104
+ function resolveDisplayedSessionKey(entry, sessionFile) {
105
+ if (entry.platform === "slack") {
106
+ const fileName = basename(sessionFile, ".jsonl");
107
+ if (/^\d+\.\d+$/.test(fileName)) {
108
+ return `${entry.conversationId}:${fileName}`;
109
+ }
110
+ return entry.conversationId;
111
+ }
112
+ return entry.sessionKey;
113
+ }
114
+ function sessionStreamKey(entry) {
115
+ return `${entry.platform}:${entry.conversationId}:${entry.sessionKey}`;
116
+ }
117
+ function renderTimelineItems(items, token) {
118
+ return items.length > 0
119
+ ? items.map((item) => renderItem(item, token)).join("\n")
53
120
  : `<div class="system-event"><span class="event-dot"></span><span class="event-text">No messages yet — send one to the bot, then refresh.</span></div>`;
121
+ }
122
+ function renderSessionPage(model, token, expiresAt, isRunning, displayedSessionKey, conversationId) {
123
+ const items = renderTimelineItems(model.items, token);
54
124
  const relatedSections = model.parent
55
125
  ? `<section class="related-card stack">
56
126
  <p class="eyebrow">Forked from</p>
@@ -62,32 +132,50 @@ function renderSessionPage(model, token, expiresAt) {
62
132
  <div class="hero-title-group">
63
133
  <span class="hero-wordmark">mama</span>
64
134
  <h1 class="hero-title">${esc(model.title)}</h1>
135
+ <div class="hero-meta-line">
136
+ <span>Created ${esc(formatDate(model.createdAt))}</span>
137
+ <span>Updated <strong data-session-updated>${esc(formatDate(model.updatedAt))}</strong></span>
138
+ <span><strong data-session-entries>${esc(String(model.entryCount))}</strong> entries</span>
139
+ </div>
140
+ </div>
141
+ <div class="hero-side">
142
+ <span class="hero-badge hero-badge-status${isRunning ? " is-running" : ""}"><span class="hero-badge-dot"></span><strong data-session-status>${esc(isRunning ? "Running" : "Idle")}</strong></span>
143
+ <span class="hero-badge">${esc(displayedSessionKey === conversationId ? "Channel" : "Thread")}</span>
65
144
  </div>
66
- <button class="refresh-btn" onclick="window.location.reload()">
67
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M12.5 2.5A6 6 0 1 0 13 7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M10 2.5h2.5V5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
68
- Refresh
69
- </button>
70
145
  </div>
71
- <div class="stat-row">
72
- ${renderSummaryItem("ID", model.sessionId.slice(0, 8))}
73
- ${renderSummaryItem("File", model.fileName)}
74
- ${renderSummaryItem("Created", formatDate(model.createdAt))}
75
- ${renderSummaryItem("Updated", formatDate(model.updatedAt))}
76
- ${renderSummaryItem("Entries", String(model.entryCount))}
77
- ${renderSummaryItem("Expires", formatDate(new Date(expiresAt).toISOString()))}
146
+ <div class="hero-detail-row">
147
+ <span class="hero-detail"><span class="hero-detail-label">Session</span><code>${esc(model.sessionId.slice(0, 8))}</code></span>
148
+ <span class="hero-detail"><span class="hero-detail-label">File</span><code>${esc(model.fileName)}</code></span>
149
+ <span class="hero-detail"><span class="hero-detail-label">Expires</span><span>${esc(formatDate(new Date(expiresAt).toISOString()))}</span></span>
78
150
  </div>
79
151
  </header>
80
152
 
81
153
  ${relatedSections}
82
154
 
83
155
  <main class="timeline-shell">
84
- <div class="timeline-list">
156
+ <div class="timeline-list" data-timeline-list>
85
157
  ${items}
86
158
  </div>
87
- </main>`);
88
- }
89
- function renderSummaryItem(label, value) {
90
- return `<span class="stat-chip"><span class="stat-label">${esc(label)}</span><strong class="stat-value">${esc(value)}</strong></span>`;
159
+ </main>
160
+
161
+ <button class="jump-latest-btn" type="button" hidden data-jump-latest aria-label="Jump to latest" title="Jump to latest">↓</button>
162
+
163
+ <section class="composer-card">
164
+ <div class="composer-copy">
165
+ <p class="eyebrow">Interactive preview</p>
166
+ <p>Ask mama in this same session. Replies stay in Session View and do not post back to Slack.</p>
167
+ </div>
168
+ <form class="composer-form" data-session-composer>
169
+ <input type="hidden" name="token" value="${esc(token)}">
170
+ <input type="hidden" name="session" value="${esc(model.fileName)}">
171
+ <input type="hidden" name="sessionKey" value="${esc(displayedSessionKey)}">
172
+ <textarea name="text" rows="3" placeholder="Ask a follow-up…" required></textarea>
173
+ <div class="composer-actions">
174
+ <span class="composer-status" data-composer-status></span>
175
+ <button class="composer-send-btn" type="submit" aria-label="Send" title="Send">↑</button>
176
+ </div>
177
+ </form>
178
+ </section>`, isRunning);
91
179
  }
92
180
  function renderRelationCard(relation, token) {
93
181
  const href = `/session?token=${encodeURIComponent(token)}&session=${encodeURIComponent(relation.fileName)}`;
@@ -172,7 +260,7 @@ function renderItem(item, token) {
172
260
  const { username, threadTs, header, content } = parsed;
173
261
  const initial = username ? esc(username.slice(0, 2).toUpperCase()) : "U";
174
262
  const rawHeader = header ? `<div class="msg-raw-header">${esc(header)}</div>` : "";
175
- const body = content ? `<pre class="msg-body">${esc(content)}</pre>` : "";
263
+ const body = content ? renderMarkdownBlock(content, "user") : "";
176
264
  const threadBadge = threadTs
177
265
  ? `<div class="thread-badge" title="Thread ${esc(threadTs)}">Thread · <code>${esc(threadTs)}</code></div>`
178
266
  : "";
@@ -189,7 +277,7 @@ function renderItem(item, token) {
189
277
  </div>`;
190
278
  }
191
279
  // assistant
192
- const body = item.body ? `<pre class="msg-body">${esc(item.body)}</pre>` : "";
280
+ const body = item.body ? renderMarkdownBlock(item.body, "assistant") : "";
193
281
  const forks = renderForkLinks(item.forks, token ?? "");
194
282
  return `<div class="msg-row msg-assistant">
195
283
  <div class="msg-avatar asst-avatar" aria-hidden="true">A</div>
@@ -200,14 +288,251 @@ function renderItem(item, token) {
200
288
  </div>
201
289
  </div>`;
202
290
  }
291
+ function renderMarkdownBlock(text, variant) {
292
+ return `<div class="msg-body markdown-body markdown-${variant}">${markdown.render(text)}</div>`;
293
+ }
294
+ function renderLiveUserMessage(text, userName) {
295
+ const initial = esc(userName.slice(0, 2).toUpperCase());
296
+ return `<div class="msg-row msg-user" data-live-item>
297
+ <div class="user-bubble">
298
+ ${renderMarkdownBlock(text, "user")}
299
+ </div>
300
+ <div class="msg-avatar user-avatar" title="${esc(userName)}">${initial}</div>
301
+ </div>`;
302
+ }
303
+ function renderLiveAssistantMessage(text) {
304
+ return `<div class="msg-row msg-assistant" data-live-assistant>
305
+ <div class="msg-avatar asst-avatar" aria-hidden="true">A</div>
306
+ <div class="asst-card">
307
+ ${renderMarkdownBlock(text, "assistant")}
308
+ </div>
309
+ </div>`;
310
+ }
311
+ function renderLiveToolResult(result) {
312
+ const toneClass = result.isError ? " tone-err" : " tone-ok";
313
+ return `<div class="tool-block" data-live-item>
314
+ <div class="tool-header">
315
+ <span class="tool-icon"><svg width="10" height="10" viewBox="0 0 10 10" fill="none"><path d="M1.5 2L5 5.5 1.5 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M6 9h2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span>
316
+ <span class="tool-name">${esc(result.toolName)}</span>
317
+ </div>
318
+ <pre class="tool-output${toneClass}">${esc(result.result)}</pre>
319
+ </div>`;
320
+ }
321
+ function renderLiveSystemEvent(text, tone = "default") {
322
+ const cls = tone === "err" ? " system-event-err" : "";
323
+ return `<div class="system-event${cls}" data-live-item><span class="event-dot"></span><span class="event-text">${esc(text)}</span></div>`;
324
+ }
325
+ async function handleSessionStreamRequest(req, res, url, sessionViewTokenStore, interactive) {
326
+ const token = url.searchParams.get("token")?.trim() ?? "";
327
+ if (!token || !sessionViewTokenStore || !interactive) {
328
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
329
+ res.end("Session stream unavailable");
330
+ return;
331
+ }
332
+ const entry = sessionViewTokenStore.peek(token);
333
+ if (!entry) {
334
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
335
+ res.end("Invalid session token");
336
+ return;
337
+ }
338
+ const requestedSession = url.searchParams.get("session");
339
+ const targetSessionFile = resolveRequestedSessionFile(entry.sessionFile, requestedSession);
340
+ if (!targetSessionFile) {
341
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
342
+ res.end("Invalid session file");
343
+ return;
344
+ }
345
+ const activeSessionKey = resolveDisplayedSessionKey(entry, targetSessionFile);
346
+ const streamKey = sessionStreamKey({ ...entry, sessionKey: activeSessionKey });
347
+ res.writeHead(200, {
348
+ "Content-Type": "text/event-stream; charset=utf-8",
349
+ "Cache-Control": "no-store",
350
+ Connection: "keep-alive",
351
+ });
352
+ res.write(`data: ${JSON.stringify({ type: "status", running: interactive.handler.isRunning(activeSessionKey) })}\n\n`);
353
+ const unsubscribe = sessionViewStreamHub.subscribe(streamKey, (event) => {
354
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
355
+ });
356
+ const heartbeat = setInterval(() => {
357
+ res.write(": keep-alive\n\n");
358
+ }, 15000);
359
+ req.on("close", () => {
360
+ clearInterval(heartbeat);
361
+ unsubscribe();
362
+ });
363
+ }
364
+ async function handleSessionMessageRequest(req, res, sessionViewTokenStore, interactive) {
365
+ if (!sessionViewTokenStore || !interactive) {
366
+ json(res, 503, { ok: false, error: "Session chat is not configured." });
367
+ return;
368
+ }
369
+ let body;
370
+ try {
371
+ body = JSON.parse(await readRequestBody(req));
372
+ }
373
+ catch {
374
+ json(res, 400, { ok: false, error: "Invalid request body." });
375
+ return;
376
+ }
377
+ const token = body.token?.trim() ?? "";
378
+ const text = body.text?.trim() ?? "";
379
+ const requestedSession = body.session?.trim() || null;
380
+ const requestedSessionKey = body.sessionKey?.trim() || "";
381
+ if (!token || !text) {
382
+ json(res, 400, { ok: false, error: "Missing token or text." });
383
+ return;
384
+ }
385
+ const entry = sessionViewTokenStore.peek(token);
386
+ if (!entry) {
387
+ json(res, 400, { ok: false, error: "This session link is invalid or has expired." });
388
+ return;
389
+ }
390
+ const targetSessionFile = resolveRequestedSessionFile(entry.sessionFile, requestedSession);
391
+ if (!targetSessionFile) {
392
+ json(res, 400, { ok: false, error: "Invalid session file." });
393
+ return;
394
+ }
395
+ const activeSessionKey = resolveDisplayedSessionKey(entry, targetSessionFile);
396
+ if (requestedSessionKey && requestedSessionKey !== activeSessionKey) {
397
+ json(res, 400, { ok: false, error: "Session target mismatch." });
398
+ return;
399
+ }
400
+ const bot = interactive.botsByPlatform[entry.platform];
401
+ if (!bot) {
402
+ json(res, 503, { ok: false, error: `No bot configured for ${entry.platform}.` });
403
+ return;
404
+ }
405
+ const streamKey = sessionStreamKey({ ...entry, sessionKey: activeSessionKey });
406
+ const conversationKind = inferConversationKind(entry.platform, entry.conversationId);
407
+ const ts = (Date.now() / 1000).toFixed(6);
408
+ const platformInfo = bot.getPlatformInfo();
409
+ const platformUserName = entry.platformUserName ||
410
+ platformInfo.users.find((user) => user.id === entry.platformUserId)?.userName ||
411
+ platformInfo.users.find((user) => user.id === entry.platformUserId)?.displayName ||
412
+ "unknown";
413
+ const responseCtx = createSessionViewResponseContext((event) => {
414
+ sessionViewStreamHub.publish(streamKey, event);
415
+ });
416
+ const event = {
417
+ type: "session_view",
418
+ conversationId: entry.conversationId,
419
+ conversationKind,
420
+ ts,
421
+ user: entry.platformUserId,
422
+ text,
423
+ attachments: [],
424
+ sessionKey: activeSessionKey,
425
+ ...(activeSessionKey.includes(":")
426
+ ? { thread_ts: activeSessionKey.split(":").slice(1).join(":") }
427
+ : {}),
428
+ };
429
+ const adapters = {
430
+ message: {
431
+ id: ts,
432
+ sessionKey: activeSessionKey,
433
+ conversationKind,
434
+ userId: entry.platformUserId,
435
+ userName: platformUserName,
436
+ text,
437
+ attachments: [],
438
+ threadTs: event.thread_ts,
439
+ },
440
+ responseCtx,
441
+ platform: { ...platformInfo, diagnostics: { showUsageSummary: false } },
442
+ };
443
+ sessionViewStreamHub.publish(streamKey, { type: "status", running: true });
444
+ sessionViewStreamHub.publish(streamKey, {
445
+ type: "user",
446
+ html: renderLiveUserMessage(text, platformUserName),
447
+ });
448
+ void interactive.handler
449
+ .handleEvent(event, bot, adapters, false)
450
+ .then(() => {
451
+ if (!targetSessionFile) {
452
+ sessionViewStreamHub.publish(streamKey, { type: "status", running: false });
453
+ return;
454
+ }
455
+ const model = loadSessionViewModel(targetSessionFile);
456
+ sessionViewStreamHub.publish(streamKey, {
457
+ type: "refresh",
458
+ timelineHtml: renderTimelineItems(model.items, token),
459
+ updatedAt: formatDate(model.updatedAt),
460
+ entryCount: model.entryCount,
461
+ running: false,
462
+ });
463
+ })
464
+ .catch((error) => {
465
+ log.logWarning(`[${entry.conversationId}] Session view message failed`, error instanceof Error ? error.message : String(error));
466
+ sessionViewStreamHub.publish(streamKey, {
467
+ type: "error",
468
+ message: error instanceof Error ? error.message : String(error),
469
+ });
470
+ sessionViewStreamHub.publish(streamKey, { type: "status", running: false });
471
+ });
472
+ json(res, 202, { ok: true, accepted: true });
473
+ }
474
+ function createSessionViewResponseContext(publish) {
475
+ let accumulatedText = "";
476
+ return {
477
+ respond: async (text) => {
478
+ accumulatedText = accumulatedText ? `${accumulatedText}\n${text}` : text;
479
+ publish({ type: "assistant", html: renderLiveAssistantMessage(accumulatedText) });
480
+ },
481
+ replaceResponse: async (text) => {
482
+ accumulatedText = text;
483
+ publish({ type: "assistant", html: renderLiveAssistantMessage(accumulatedText) });
484
+ },
485
+ respondDiagnostic: async (text, options) => {
486
+ if (options?.style === "error") {
487
+ publish({ type: "system", html: renderLiveSystemEvent(text, "err") });
488
+ }
489
+ },
490
+ respondToolResult: async (result) => {
491
+ publish({ type: "tool", html: renderLiveToolResult(result) });
492
+ },
493
+ setTyping: async () => {
494
+ publish({ type: "status", running: true });
495
+ },
496
+ setWorking: async (working) => {
497
+ publish({ type: "status", running: working });
498
+ },
499
+ uploadFile: async () => { },
500
+ deleteResponse: async () => {
501
+ accumulatedText = "";
502
+ publish({ type: "assistant_remove" });
503
+ },
504
+ };
505
+ }
506
+ function readRequestBody(req) {
507
+ return new Promise((resolve, reject) => {
508
+ let data = "";
509
+ req.setEncoding("utf8");
510
+ req.on("data", (chunk) => {
511
+ data += chunk;
512
+ if (data.length > 1024 * 1024) {
513
+ reject(new Error("Request body too large"));
514
+ req.destroy();
515
+ }
516
+ });
517
+ req.on("end", () => resolve(data));
518
+ req.on("error", reject);
519
+ });
520
+ }
521
+ function json(res, status, body) {
522
+ res.writeHead(status, {
523
+ "Content-Type": "application/json; charset=utf-8",
524
+ "Cache-Control": "no-store",
525
+ });
526
+ res.end(JSON.stringify(body));
527
+ }
203
528
  function renderStatusPage(title, message) {
204
529
  return renderHtmlDocument(title, `<section class="card stack">
205
530
  <p class="eyebrow">mama</p>
206
531
  <h1>${esc(title)}</h1>
207
532
  <div class="status err">${esc(message)}</div>
208
- </section>`);
533
+ </section>`, false);
209
534
  }
210
- function renderHtmlDocument(title, shellContent) {
535
+ function renderHtmlDocument(title, shellContent, isRunning) {
211
536
  return `<!DOCTYPE html>
212
537
  <html lang="en">
213
538
  <head>
@@ -216,10 +541,154 @@ function renderHtmlDocument(title, shellContent) {
216
541
  <title>${esc(title)}</title>
217
542
  <style>${styles}</style>
218
543
  </head>
219
- <body>
544
+ <body data-session-running="${isRunning ? "true" : "false"}">
220
545
  <main class="shell">
221
546
  ${shellContent}
222
547
  </main>
548
+ <script>
549
+ const form = document.querySelector('[data-session-composer]');
550
+ const timelineList = document.querySelector('[data-timeline-list]');
551
+ const jumpLatestBtn = document.querySelector('[data-jump-latest]');
552
+ const statusEl = document.querySelector('[data-session-status]');
553
+ const updatedEl = document.querySelector('[data-session-updated]');
554
+ const entriesEl = document.querySelector('[data-session-entries]');
555
+ const composerStatus = form?.querySelector('[data-composer-status]');
556
+ const textarea = form?.querySelector('textarea[name="text"]');
557
+ const submitButton = form?.querySelector('button[type="submit"]');
558
+ let liveAssistant = null;
559
+ let running = document.body.dataset.sessionRunning === 'true';
560
+
561
+ const isNearBottom = () => window.innerHeight + window.scrollY >= document.body.offsetHeight - 120;
562
+ const scrollToLatest = (behavior = 'smooth') => window.scrollTo({ top: document.body.scrollHeight, behavior });
563
+ const toggleJumpButton = () => {
564
+ if (!jumpLatestBtn) return;
565
+ jumpLatestBtn.hidden = isNearBottom();
566
+ };
567
+ const updateFollowState = () => {
568
+ if (isNearBottom()) scrollToLatest('smooth');
569
+ else toggleJumpButton();
570
+ };
571
+ const canSubmit = () => Boolean(textarea && textarea.value.trim()) && !running;
572
+ const updateSubmitButtonState = () => {
573
+ if (submitButton) submitButton.disabled = !canSubmit();
574
+ };
575
+ const setRunning = (value) => {
576
+ running = value;
577
+ document.body.dataset.sessionRunning = value ? 'true' : 'false';
578
+ if (statusEl) statusEl.textContent = value ? 'Running' : 'Idle';
579
+ updateSubmitButtonState();
580
+ if (composerStatus && !value && composerStatus.textContent === 'Thinking…') {
581
+ composerStatus.textContent = '';
582
+ }
583
+ };
584
+
585
+ jumpLatestBtn?.addEventListener('click', () => {
586
+ scrollToLatest('smooth');
587
+ toggleJumpButton();
588
+ });
589
+ window.addEventListener('scroll', toggleJumpButton, { passive: true });
590
+
591
+ if (textarea) {
592
+ const resize = () => {
593
+ textarea.style.height = 'auto';
594
+ textarea.style.height = Math.min(textarea.scrollHeight, 240) + 'px';
595
+ };
596
+ textarea.addEventListener('input', () => {
597
+ resize();
598
+ updateSubmitButtonState();
599
+ });
600
+ textarea.addEventListener('keydown', (event) => {
601
+ if (event.key === 'Enter' && !event.shiftKey) {
602
+ event.preventDefault();
603
+ if (!running) form?.requestSubmit();
604
+ }
605
+ });
606
+ resize();
607
+ }
608
+
609
+ setRunning(running);
610
+ updateSubmitButtonState();
611
+
612
+ const streamUrl = form
613
+ ? '/session/stream?token=' + encodeURIComponent(form.token.value) + '&session=' + encodeURIComponent(form.session.value)
614
+ : null;
615
+ if (streamUrl) {
616
+ const source = new EventSource(streamUrl);
617
+ source.onmessage = (event) => {
618
+ const payload = JSON.parse(event.data);
619
+ switch (payload.type) {
620
+ case 'status':
621
+ setRunning(Boolean(payload.running));
622
+ if (payload.running && composerStatus) composerStatus.textContent = 'Thinking…';
623
+ break;
624
+ case 'user':
625
+ case 'tool':
626
+ case 'system': {
627
+ timelineList?.insertAdjacentHTML('beforeend', payload.html);
628
+ updateFollowState();
629
+ break;
630
+ }
631
+ case 'assistant': {
632
+ if (!liveAssistant || !liveAssistant.isConnected) {
633
+ timelineList?.insertAdjacentHTML('beforeend', payload.html);
634
+ liveAssistant = timelineList?.querySelector('[data-live-assistant]:last-of-type') || null;
635
+ } else {
636
+ liveAssistant.outerHTML = payload.html;
637
+ liveAssistant = timelineList?.querySelector('[data-live-assistant]:last-of-type') || null;
638
+ }
639
+ updateFollowState();
640
+ break;
641
+ }
642
+ case 'assistant_remove':
643
+ if (liveAssistant?.isConnected) liveAssistant.remove();
644
+ liveAssistant = null;
645
+ break;
646
+ case 'refresh':
647
+ if (timelineList) timelineList.innerHTML = payload.timelineHtml;
648
+ liveAssistant = null;
649
+ if (updatedEl) updatedEl.textContent = payload.updatedAt;
650
+ if (entriesEl) entriesEl.textContent = String(payload.entryCount);
651
+ setRunning(Boolean(payload.running));
652
+ if (composerStatus) composerStatus.textContent = '';
653
+ updateFollowState();
654
+ break;
655
+ case 'error':
656
+ if (composerStatus) composerStatus.textContent = payload.message || 'Something went wrong';
657
+ setRunning(false);
658
+ break;
659
+ }
660
+ };
661
+ }
662
+
663
+ form?.addEventListener('submit', async (event) => {
664
+ event.preventDefault();
665
+ if (!textarea || !composerStatus) return;
666
+ const text = textarea.value.trim();
667
+ if (!text || running) return;
668
+ composerStatus.textContent = 'Sending…';
669
+ updateSubmitButtonState();
670
+ try {
671
+ const response = await fetch('/session/message', {
672
+ method: 'POST',
673
+ headers: { 'Content-Type': 'application/json' },
674
+ body: JSON.stringify({ token: form.token.value, session: form.session.value, sessionKey: form.sessionKey.value, text }),
675
+ });
676
+ const payload = await response.json();
677
+ if (!response.ok || !payload.ok) throw new Error(payload.error || 'Request failed');
678
+ textarea.value = '';
679
+ textarea.style.height = 'auto';
680
+ composerStatus.textContent = 'Thinking…';
681
+ setRunning(true);
682
+ updateSubmitButtonState();
683
+ scrollToLatest('smooth');
684
+ } catch (err) {
685
+ composerStatus.textContent = err && err.message ? err.message : String(err);
686
+ submitButton.disabled = false;
687
+ }
688
+ });
689
+
690
+ toggleJumpButton();
691
+ </script>
223
692
  </body>
224
693
  </html>`;
225
694
  }
@@ -278,6 +747,7 @@ const styles = `
278
747
  display: flex;
279
748
  flex-direction: column;
280
749
  align-items: center;
750
+ overflow-x: hidden;
281
751
  background-color: var(--bg);
282
752
  background-image:
283
753
  radial-gradient(ellipse 80% 40% at 50% -10%, rgba(255,255,255,0.6) 0%, transparent 70%);
@@ -291,6 +761,7 @@ const styles = `
291
761
  .shell {
292
762
  width: 100%;
293
763
  max-width: 780px;
764
+ min-width: 0;
294
765
  display: flex;
295
766
  flex-direction: column;
296
767
  gap: 12px;
@@ -310,8 +781,8 @@ const styles = `
310
781
  display: flex;
311
782
  align-items: flex-start;
312
783
  justify-content: space-between;
313
- gap: 16px;
314
- margin-bottom: 20px;
784
+ gap: 20px;
785
+ margin-bottom: 18px;
315
786
  }
316
787
 
317
788
  .hero-wordmark {
@@ -332,61 +803,103 @@ const styles = `
332
803
  letter-spacing: -0.01em;
333
804
  color: var(--text);
334
805
  text-wrap: balance;
806
+ margin-bottom: 8px;
807
+ }
808
+
809
+ .hero-meta-line {
810
+ display: flex;
811
+ flex-wrap: wrap;
812
+ gap: 8px 14px;
813
+ color: var(--muted);
814
+ font-size: 0.82rem;
815
+ line-height: 1.4;
816
+ }
817
+
818
+ .hero-meta-line strong {
819
+ color: var(--text);
820
+ font-weight: 600;
821
+ }
822
+
823
+ .hero-side {
824
+ display: flex;
825
+ flex-direction: column;
826
+ align-items: flex-end;
827
+ gap: 8px;
828
+ flex-shrink: 0;
335
829
  }
336
830
 
337
- .refresh-btn {
831
+ .hero-badge {
338
832
  display: inline-flex;
339
833
  align-items: center;
340
- gap: 6px;
341
- flex-shrink: 0;
342
- padding: 7px 14px;
834
+ gap: 8px;
835
+ padding: 6px 11px;
343
836
  border: 1px solid var(--border);
344
837
  border-radius: 999px;
345
- background: transparent;
838
+ background: rgba(255,255,255,0.7);
839
+ font-size: 0.78rem;
346
840
  color: var(--muted);
347
- font: 500 0.8rem/1 'DM Sans', sans-serif;
348
- cursor: pointer;
349
- transition: color 120ms, border-color 120ms, background 120ms;
350
- white-space: nowrap;
841
+ line-height: 1;
351
842
  }
352
843
 
353
- .refresh-btn:hover {
844
+ .hero-badge strong {
354
845
  color: var(--text);
355
- border-color: rgba(0,0,0,0.2);
356
- background: rgba(0,0,0,0.03);
846
+ font-weight: 600;
847
+ }
848
+
849
+ .hero-badge-status.is-running {
850
+ background: #fff7ed;
851
+ border-color: rgba(217, 119, 6, 0.18);
852
+ color: #9a3412;
853
+ }
854
+
855
+ .hero-badge-dot {
856
+ width: 7px;
857
+ height: 7px;
858
+ border-radius: 50%;
859
+ background: #a1a1aa;
860
+ flex-shrink: 0;
357
861
  }
358
862
 
359
- .refresh-btn:focus-visible {
360
- outline: 2px solid var(--text);
361
- outline-offset: 2px;
863
+ .hero-badge-status.is-running .hero-badge-dot {
864
+ background: #d97706;
865
+ box-shadow: 0 0 0 4px rgba(217, 119, 6, 0.14);
362
866
  }
363
867
 
364
- .stat-row {
868
+ .hero-detail-row {
365
869
  display: flex;
366
870
  flex-wrap: wrap;
367
- gap: 6px;
871
+ gap: 10px;
872
+ padding-top: 14px;
873
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
368
874
  }
369
875
 
370
- .stat-chip {
876
+ .hero-detail {
371
877
  display: inline-flex;
372
878
  align-items: center;
373
- gap: 5px;
374
- padding: 4px 10px;
375
- border: 1px solid var(--border);
376
- border-radius: 999px;
377
- background: #f4f4f5;
378
- font-size: 0.775rem;
379
- line-height: 1;
879
+ gap: 8px;
880
+ min-width: 0;
881
+ padding: 6px 10px;
882
+ border-radius: 12px;
883
+ background: rgba(0, 0, 0, 0.025);
884
+ color: var(--muted);
885
+ font-size: 0.78rem;
380
886
  }
381
887
 
382
- .stat-label {
383
- color: var(--muted);
384
- font-weight: 500;
888
+ .hero-detail-label {
889
+ text-transform: uppercase;
890
+ letter-spacing: 0.08em;
891
+ font-size: 0.68rem;
892
+ color: var(--subtle);
385
893
  }
386
894
 
387
- .stat-value {
895
+ .hero-detail code {
896
+ min-width: 0;
897
+ overflow: hidden;
898
+ text-overflow: ellipsis;
899
+ white-space: nowrap;
900
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
901
+ font-size: 0.74rem;
388
902
  color: var(--text);
389
- font-weight: 600;
390
903
  }
391
904
 
392
905
  /* ── Timeline shell ───────────────────────────────────────────────────── */
@@ -508,6 +1021,7 @@ const styles = `
508
1021
  display: flex;
509
1022
  flex-direction: column;
510
1023
  gap: 4px;
1024
+ min-width: 0;
511
1025
  }
512
1026
 
513
1027
  /* ── Message rows ─────────────────────────────────────────────────────── */
@@ -517,6 +1031,7 @@ const styles = `
517
1031
  align-items: flex-end;
518
1032
  gap: 8px;
519
1033
  padding: 2px 0;
1034
+ min-width: 0;
520
1035
  }
521
1036
 
522
1037
  /* ── User messages ────────────────────────────────────────────────────── */
@@ -527,6 +1042,7 @@ const styles = `
527
1042
 
528
1043
  .user-bubble {
529
1044
  max-width: 85%;
1045
+ min-width: 0;
530
1046
  padding: 12px 16px;
531
1047
  border-radius: 18px 18px 4px 18px;
532
1048
  background: var(--user-bg);
@@ -568,11 +1084,6 @@ const styles = `
568
1084
  }
569
1085
 
570
1086
  .msg-user .msg-body {
571
- font-family: 'DM Sans', system-ui, sans-serif;
572
- font-size: 0.9rem;
573
- line-height: 1.6;
574
- white-space: pre-wrap;
575
- word-break: break-word;
576
1087
  color: var(--user-text);
577
1088
  }
578
1089
 
@@ -623,6 +1134,7 @@ const styles = `
623
1134
 
624
1135
  .asst-card {
625
1136
  min-width: 0;
1137
+ max-width: 100%;
626
1138
  padding: 14px 18px;
627
1139
  border: 1px solid var(--border);
628
1140
  border-radius: 18px 18px 18px 4px;
@@ -631,11 +1143,6 @@ const styles = `
631
1143
  }
632
1144
 
633
1145
  .msg-assistant .msg-body {
634
- font-family: 'DM Sans', system-ui, sans-serif;
635
- font-size: 0.9rem;
636
- line-height: 1.65;
637
- white-space: pre-wrap;
638
- word-break: break-word;
639
1146
  color: var(--text);
640
1147
  }
641
1148
 
@@ -711,6 +1218,138 @@ const styles = `
711
1218
  .tool-output.tone-ok { color: var(--tool-ok); }
712
1219
  .tool-output.tone-err { color: var(--tool-err); }
713
1220
 
1221
+ /* ── Markdown blocks ──────────────────────────────────────────────────── */
1222
+
1223
+ .markdown-body {
1224
+ font-family: 'DM Sans', system-ui, sans-serif;
1225
+ font-size: 0.9rem;
1226
+ line-height: 1.65;
1227
+ word-break: break-word;
1228
+ }
1229
+
1230
+ .markdown-body > *:first-child { margin-top: 0; }
1231
+ .markdown-body > *:last-child { margin-bottom: 0; }
1232
+ .markdown-body p,
1233
+ .markdown-body ul,
1234
+ .markdown-body ol,
1235
+ .markdown-body blockquote,
1236
+ .markdown-body pre,
1237
+ .markdown-body table,
1238
+ .markdown-body hr {
1239
+ margin: 0 0 0.85em;
1240
+ }
1241
+
1242
+ .markdown-body h1,
1243
+ .markdown-body h2,
1244
+ .markdown-body h3,
1245
+ .markdown-body h4,
1246
+ .markdown-body h5,
1247
+ .markdown-body h6 {
1248
+ margin: 0 0 0.55em;
1249
+ line-height: 1.25;
1250
+ font-weight: 700;
1251
+ letter-spacing: -0.01em;
1252
+ }
1253
+
1254
+ .markdown-body h1 { font-size: 1.4rem; }
1255
+ .markdown-body h2 { font-size: 1.22rem; }
1256
+ .markdown-body h3 { font-size: 1.08rem; }
1257
+ .markdown-body h4,
1258
+ .markdown-body h5,
1259
+ .markdown-body h6 { font-size: 0.95rem; }
1260
+
1261
+ .markdown-body ul,
1262
+ .markdown-body ol {
1263
+ padding-left: 1.3em;
1264
+ }
1265
+
1266
+ .markdown-body li + li {
1267
+ margin-top: 0.22em;
1268
+ }
1269
+
1270
+ .markdown-body blockquote {
1271
+ padding-left: 12px;
1272
+ border-left: 3px solid rgba(34, 197, 94, 0.35);
1273
+ opacity: 0.95;
1274
+ }
1275
+
1276
+ .markdown-body a {
1277
+ color: inherit;
1278
+ text-decoration: underline;
1279
+ text-underline-offset: 2px;
1280
+ }
1281
+
1282
+ .markdown-body code {
1283
+ font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
1284
+ font-size: 0.82em;
1285
+ padding: 0.16em 0.38em;
1286
+ border-radius: 6px;
1287
+ }
1288
+
1289
+ .markdown-body pre {
1290
+ overflow-x: auto;
1291
+ border-radius: 12px;
1292
+ padding: 12px 14px;
1293
+ }
1294
+
1295
+ .markdown-body pre code {
1296
+ display: block;
1297
+ padding: 0;
1298
+ border-radius: 0;
1299
+ background: transparent;
1300
+ font-size: 0.82rem;
1301
+ line-height: 1.6;
1302
+ }
1303
+
1304
+ .markdown-body table {
1305
+ width: 100%;
1306
+ border-collapse: collapse;
1307
+ font-size: 0.85rem;
1308
+ }
1309
+
1310
+ .markdown-body th,
1311
+ .markdown-body td {
1312
+ padding: 8px 10px;
1313
+ border: 1px solid rgba(0, 0, 0, 0.08);
1314
+ text-align: left;
1315
+ vertical-align: top;
1316
+ }
1317
+
1318
+ .markdown-body img {
1319
+ max-width: 100%;
1320
+ border-radius: 12px;
1321
+ }
1322
+
1323
+ .markdown-user code {
1324
+ background: rgba(255,255,255,0.14);
1325
+ color: var(--user-text);
1326
+ }
1327
+
1328
+ .markdown-user pre {
1329
+ background: rgba(255,255,255,0.08);
1330
+ border: 1px solid rgba(255,255,255,0.08);
1331
+ }
1332
+
1333
+ .markdown-user table th,
1334
+ .markdown-user table td {
1335
+ border-color: rgba(255,255,255,0.16);
1336
+ }
1337
+
1338
+ .markdown-assistant code {
1339
+ background: #f4f4f5;
1340
+ color: #27272a;
1341
+ }
1342
+
1343
+ .markdown-assistant pre {
1344
+ background: #0f172a;
1345
+ color: #e5e7eb;
1346
+ }
1347
+
1348
+ .markdown-assistant table th,
1349
+ .markdown-assistant table td {
1350
+ border-color: rgba(0, 0, 0, 0.08);
1351
+ }
1352
+
714
1353
  /* ── System events ────────────────────────────────────────────────────── */
715
1354
 
716
1355
  .system-event {
@@ -736,6 +1375,10 @@ const styles = `
736
1375
  color: var(--muted);
737
1376
  }
738
1377
 
1378
+ .system-event-err .event-text {
1379
+ color: var(--err-text);
1380
+ }
1381
+
739
1382
  .event-time {
740
1383
  color: var(--subtle);
741
1384
  font-style: normal;
@@ -783,6 +1426,119 @@ const styles = `
783
1426
  border: 1px solid rgba(185, 28, 28, 0.12);
784
1427
  }
785
1428
 
1429
+ /* ── Composer ─────────────────────────────────────────────────────────── */
1430
+
1431
+ .composer-card {
1432
+ padding: 8px 2px 0;
1433
+ border: none;
1434
+ border-radius: 0;
1435
+ background: transparent;
1436
+ box-shadow: none;
1437
+ }
1438
+
1439
+ .jump-latest-btn {
1440
+ position: fixed;
1441
+ left: 50%;
1442
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
1443
+ z-index: 10;
1444
+ width: 42px;
1445
+ height: 42px;
1446
+ border: 1px solid var(--border);
1447
+ border-radius: 999px;
1448
+ background: var(--bg);
1449
+ color: var(--text);
1450
+ font: 700 1rem/1 'DM Sans', sans-serif;
1451
+ box-shadow: 0 10px 30px rgba(0,0,0,0.12);
1452
+ cursor: pointer;
1453
+ backdrop-filter: blur(10px);
1454
+ transform: translateX(-50%);
1455
+ outline: none;
1456
+ appearance: none;
1457
+ -webkit-tap-highlight-color: transparent;
1458
+ }
1459
+
1460
+ .jump-latest-btn:hover {
1461
+ transform: translateX(-50%) translateY(-1px);
1462
+ background: #e8e3d9;
1463
+ }
1464
+
1465
+ .jump-latest-btn:focus,
1466
+ .jump-latest-btn:active {
1467
+ outline: none;
1468
+ }
1469
+
1470
+ .jump-latest-btn:focus-visible {
1471
+ box-shadow: 0 10px 30px rgba(0,0,0,0.12), 0 0 0 3px rgba(0,0,0,0.08);
1472
+ }
1473
+
1474
+ .composer-copy { margin-bottom: 12px; color: var(--muted); }
1475
+
1476
+ .composer-form textarea {
1477
+ width: 100%;
1478
+ resize: none;
1479
+ overflow-y: auto;
1480
+ min-height: 84px;
1481
+ padding: 12px 14px;
1482
+ border: 1px solid var(--border);
1483
+ border-radius: 14px;
1484
+ font: inherit;
1485
+ color: var(--text);
1486
+ background: #fafafa;
1487
+ }
1488
+
1489
+ .composer-form textarea:focus {
1490
+ outline: 2px solid rgba(34, 197, 94, 0.18);
1491
+ border-color: rgba(34, 197, 94, 0.45);
1492
+ }
1493
+
1494
+ .composer-actions {
1495
+ display: flex;
1496
+ align-items: center;
1497
+ justify-content: space-between;
1498
+ gap: 12px;
1499
+ margin-top: 10px;
1500
+ }
1501
+
1502
+ .composer-status { color: var(--muted); font-size: 13px; }
1503
+ .composer-actions button:disabled { opacity: 0.55; cursor: wait; }
1504
+
1505
+ .composer-send-btn {
1506
+ display: inline-flex;
1507
+ align-items: center;
1508
+ justify-content: center;
1509
+ width: 42px;
1510
+ height: 42px;
1511
+ border: none;
1512
+ border-radius: 999px;
1513
+ background: #d97706;
1514
+ color: #ffffff;
1515
+ font: 700 1rem/1 'DM Sans', sans-serif;
1516
+ cursor: pointer;
1517
+ box-shadow: 0 10px 24px rgba(217, 119, 6, 0.26);
1518
+ transition: transform 120ms, filter 120ms, box-shadow 120ms, background 120ms;
1519
+ }
1520
+
1521
+ .composer-send-btn:hover:not(:disabled) {
1522
+ transform: translateY(-1px);
1523
+ filter: saturate(1.06) brightness(0.98);
1524
+ box-shadow: 0 12px 28px rgba(217, 119, 6, 0.32);
1525
+ }
1526
+
1527
+ .composer-send-btn:focus-visible {
1528
+ outline: 2px solid rgba(217, 119, 6, 0.28);
1529
+ outline-offset: 3px;
1530
+ }
1531
+
1532
+ .composer-send-btn:disabled {
1533
+ background: #d4d4d8;
1534
+ color: rgba(24, 24, 27, 0.45);
1535
+ box-shadow: none;
1536
+ transform: none;
1537
+ filter: none;
1538
+ cursor: not-allowed;
1539
+ opacity: 1;
1540
+ }
1541
+
786
1542
  /* ── Responsive ───────────────────────────────────────────────────────── */
787
1543
 
788
1544
  @media (max-width: 600px) {
@@ -791,10 +1547,12 @@ const styles = `
791
1547
  .hero-card, .card { padding: 20px; border-radius: 16px; }
792
1548
 
793
1549
  .hero-top { flex-direction: column; gap: 12px; }
1550
+ .hero-side { align-items: flex-start; }
1551
+ .hero-detail-row { gap: 8px; }
794
1552
 
795
- .refresh-btn { align-self: flex-start; }
796
-
797
- .user-bubble { max-width: 88%; }
1553
+ .user-bubble,
1554
+ .msg-assistant,
1555
+ .tool-block { max-width: 100%; }
798
1556
 
799
1557
  .asst-avatar { display: none; }
800
1558