@geminixiang/mama 0.2.0-beta.7 → 0.2.0-beta.9

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 (140) hide show
  1. package/README.md +3 -5
  2. package/dist/adapter.d.ts +2 -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 +7 -4
  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.map +1 -1
  14. package/dist/adapters/slack/bot.js +20 -6
  15. package/dist/adapters/slack/bot.js.map +1 -1
  16. package/dist/adapters/slack/branch-manager.d.ts +1 -0
  17. package/dist/adapters/slack/branch-manager.d.ts.map +1 -1
  18. package/dist/adapters/slack/branch-manager.js +9 -8
  19. package/dist/adapters/slack/branch-manager.js.map +1 -1
  20. package/dist/adapters/slack/context.d.ts +1 -1
  21. package/dist/adapters/slack/context.d.ts.map +1 -1
  22. package/dist/adapters/slack/context.js +10 -13
  23. package/dist/adapters/slack/context.js.map +1 -1
  24. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  25. package/dist/adapters/telegram/bot.js +2 -2
  26. package/dist/adapters/telegram/bot.js.map +1 -1
  27. package/dist/agent.d.ts +1 -2
  28. package/dist/agent.d.ts.map +1 -1
  29. package/dist/agent.js +477 -408
  30. package/dist/agent.js.map +1 -1
  31. package/dist/commands/login.d.ts.map +1 -1
  32. package/dist/commands/login.js +41 -2
  33. package/dist/commands/login.js.map +1 -1
  34. package/dist/commands/new.d.ts.map +1 -1
  35. package/dist/commands/new.js +1 -1
  36. package/dist/commands/new.js.map +1 -1
  37. package/dist/commands/sandbox.d.ts +1 -1
  38. package/dist/commands/sandbox.d.ts.map +1 -1
  39. package/dist/commands/sandbox.js +25 -2
  40. package/dist/commands/sandbox.js.map +1 -1
  41. package/dist/commands/session-view.d.ts.map +1 -1
  42. package/dist/commands/session-view.js +5 -1
  43. package/dist/commands/session-view.js.map +1 -1
  44. package/dist/commands/types.d.ts +1 -3
  45. package/dist/commands/types.d.ts.map +1 -1
  46. package/dist/commands/types.js.map +1 -1
  47. package/dist/config.d.ts +4 -0
  48. package/dist/config.d.ts.map +1 -1
  49. package/dist/config.js +35 -23
  50. package/dist/config.js.map +1 -1
  51. package/dist/context.d.ts +2 -44
  52. package/dist/context.d.ts.map +1 -1
  53. package/dist/context.js +7 -225
  54. package/dist/context.js.map +1 -1
  55. package/dist/events.d.ts.map +1 -1
  56. package/dist/events.js +15 -14
  57. package/dist/events.js.map +1 -1
  58. package/dist/execution-resolver.d.ts +3 -2
  59. package/dist/execution-resolver.d.ts.map +1 -1
  60. package/dist/execution-resolver.js +40 -7
  61. package/dist/execution-resolver.js.map +1 -1
  62. package/dist/file-guards.d.ts +6 -0
  63. package/dist/file-guards.d.ts.map +1 -0
  64. package/dist/file-guards.js +48 -0
  65. package/dist/file-guards.js.map +1 -0
  66. package/dist/log.d.ts +1 -5
  67. package/dist/log.d.ts.map +1 -1
  68. package/dist/log.js +13 -38
  69. package/dist/log.js.map +1 -1
  70. package/dist/login/index.d.ts +14 -2
  71. package/dist/login/index.d.ts.map +1 -1
  72. package/dist/login/index.js +40 -13
  73. package/dist/login/index.js.map +1 -1
  74. package/dist/login/portal.d.ts +2 -1
  75. package/dist/login/portal.d.ts.map +1 -1
  76. package/dist/login/portal.js +12 -12
  77. package/dist/login/portal.js.map +1 -1
  78. package/dist/main.d.ts.map +1 -1
  79. package/dist/main.js +26 -27
  80. package/dist/main.js.map +1 -1
  81. package/dist/provisioner.d.ts +0 -2
  82. package/dist/provisioner.d.ts.map +1 -1
  83. package/dist/provisioner.js +2 -4
  84. package/dist/provisioner.js.map +1 -1
  85. package/dist/runtime/conversation-orchestrator.d.ts +42 -0
  86. package/dist/runtime/conversation-orchestrator.d.ts.map +1 -0
  87. package/dist/runtime/conversation-orchestrator.js +150 -0
  88. package/dist/runtime/conversation-orchestrator.js.map +1 -0
  89. package/dist/runtime/session-runtime.d.ts +1 -1
  90. package/dist/runtime/session-runtime.d.ts.map +1 -1
  91. package/dist/runtime/session-runtime.js +49 -148
  92. package/dist/runtime/session-runtime.js.map +1 -1
  93. package/dist/sandbox/cloudflare.d.ts.map +1 -1
  94. package/dist/sandbox/cloudflare.js +2 -2
  95. package/dist/sandbox/cloudflare.js.map +1 -1
  96. package/dist/sandbox/container.d.ts.map +1 -1
  97. package/dist/sandbox/container.js +1 -1
  98. package/dist/sandbox/container.js.map +1 -1
  99. package/dist/sandbox/index.d.ts.map +1 -1
  100. package/dist/sandbox/index.js +4 -4
  101. package/dist/sandbox/index.js.map +1 -1
  102. package/dist/sentry.d.ts +1 -1
  103. package/dist/sentry.d.ts.map +1 -1
  104. package/dist/sentry.js +2 -2
  105. package/dist/sentry.js.map +1 -1
  106. package/dist/session-store.d.ts +1 -0
  107. package/dist/session-store.d.ts.map +1 -1
  108. package/dist/session-store.js +18 -14
  109. package/dist/session-store.js.map +1 -1
  110. package/dist/session-view/portal.d.ts +6 -1
  111. package/dist/session-view/portal.d.ts.map +1 -1
  112. package/dist/session-view/portal.js +1027 -89
  113. package/dist/session-view/portal.js.map +1 -1
  114. package/dist/session-view/service.d.ts.map +1 -1
  115. package/dist/session-view/service.js +4 -3
  116. package/dist/session-view/service.js.map +1 -1
  117. package/dist/session-view/store.d.ts +2 -1
  118. package/dist/session-view/store.d.ts.map +1 -1
  119. package/dist/session-view/store.js +2 -1
  120. package/dist/session-view/store.js.map +1 -1
  121. package/dist/store.d.ts.map +1 -1
  122. package/dist/store.js +7 -13
  123. package/dist/store.js.map +1 -1
  124. package/dist/tool-diagnostics.d.ts +2 -0
  125. package/dist/tool-diagnostics.d.ts.map +1 -0
  126. package/dist/tool-diagnostics.js +7 -0
  127. package/dist/tool-diagnostics.js.map +1 -0
  128. package/dist/vault-routing.d.ts +0 -3
  129. package/dist/vault-routing.d.ts.map +1 -1
  130. package/dist/vault-routing.js +0 -24
  131. package/dist/vault-routing.js.map +1 -1
  132. package/dist/vault.d.ts +21 -57
  133. package/dist/vault.d.ts.map +1 -1
  134. package/dist/vault.js +114 -246
  135. package/dist/vault.js.map +1 -1
  136. package/package.json +3 -1
  137. package/dist/bindings.d.ts +0 -45
  138. package/dist/bindings.d.ts.map +0 -1
  139. package/dist/bindings.js +0 -75
  140. 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="1" placeholder="Write a message…" 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)}`;
@@ -143,6 +231,9 @@ export function parseUserBody(raw) {
143
231
  }
144
232
  return { timestamp: null, username: null, threadTs: null, header: null, content: raw };
145
233
  }
234
+ function renderCopyButton(label = "Copy message") {
235
+ return `<div class="msg-actions"><button class="copy-action-btn" type="button" data-copy-button data-copy-label="${esc(label)}" aria-label="${esc(label)}" title="${esc(label)}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="1.8"></rect><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path></svg></button></div>`;
236
+ }
146
237
  function renderItem(item, token) {
147
238
  if (item.kind === "system") {
148
239
  const parts = [item.title, item.body].filter((x) => Boolean(x)).map(esc);
@@ -172,42 +263,291 @@ function renderItem(item, token) {
172
263
  const { username, threadTs, header, content } = parsed;
173
264
  const initial = username ? esc(username.slice(0, 2).toUpperCase()) : "U";
174
265
  const rawHeader = header ? `<div class="msg-raw-header">${esc(header)}</div>` : "";
175
- const body = content ? `<pre class="msg-body">${esc(content)}</pre>` : "";
266
+ const body = content ? renderMarkdownBlock(content, "user") : "";
176
267
  const threadBadge = threadTs
177
268
  ? `<div class="thread-badge" title="Thread ${esc(threadTs)}">Thread · <code>${esc(threadTs)}</code></div>`
178
269
  : "";
179
270
  const forks = renderForkLinks(item.forks, token ?? "");
180
- return `<div class="msg-row msg-user">
181
- <div class="user-bubble">
182
- ${rawHeader}
183
- ${threadBadge}
184
- ${body}
185
- ${forks}
186
- ${time}
271
+ return `<div class="msg-row msg-user copy-host">
272
+ <div class="msg-main user-main">
273
+ <div class="user-bubble">
274
+ ${rawHeader}
275
+ ${threadBadge}
276
+ ${body}
277
+ ${forks}
278
+ ${time}
279
+ </div>
280
+ ${renderCopyButton()}
187
281
  </div>
188
282
  <div class="msg-avatar user-avatar" title="${username ? esc(username) : "User"}">${initial}</div>
189
283
  </div>`;
190
284
  }
191
285
  // assistant
192
- const body = item.body ? `<pre class="msg-body">${esc(item.body)}</pre>` : "";
286
+ const body = item.body ? renderMarkdownBlock(item.body, "assistant") : "";
193
287
  const forks = renderForkLinks(item.forks, token ?? "");
194
- return `<div class="msg-row msg-assistant">
288
+ return `<div class="msg-row msg-assistant copy-host">
195
289
  <div class="msg-avatar asst-avatar" aria-hidden="true">A</div>
196
- <div class="asst-card">
197
- ${body}
198
- ${forks}
199
- ${time}
290
+ <div class="msg-main asst-main">
291
+ <div class="asst-card">
292
+ ${body}
293
+ ${forks}
294
+ ${time}
295
+ </div>
296
+ ${renderCopyButton()}
297
+ </div>
298
+ </div>`;
299
+ }
300
+ function renderMarkdownBlock(text, variant) {
301
+ return `<div class="msg-body markdown-body markdown-${variant}">${markdown.render(text)}</div>`;
302
+ }
303
+ function renderLiveUserMessage(text, userName) {
304
+ const initial = esc(userName.slice(0, 2).toUpperCase());
305
+ return `<div class="msg-row msg-user copy-host" data-live-item>
306
+ <div class="msg-main user-main">
307
+ <div class="user-bubble">
308
+ ${renderMarkdownBlock(text, "user")}
309
+ </div>
310
+ ${renderCopyButton()}
311
+ </div>
312
+ <div class="msg-avatar user-avatar" title="${esc(userName)}">${initial}</div>
313
+ </div>`;
314
+ }
315
+ function renderLiveAssistantMessage(text) {
316
+ return `<div class="msg-row msg-assistant copy-host" data-live-assistant>
317
+ <div class="msg-avatar asst-avatar" aria-hidden="true">A</div>
318
+ <div class="msg-main asst-main">
319
+ <div class="asst-card">
320
+ ${renderMarkdownBlock(text, "assistant")}
321
+ </div>
322
+ ${renderCopyButton()}
200
323
  </div>
201
324
  </div>`;
202
325
  }
326
+ function renderLiveToolResult(result) {
327
+ const toneClass = result.isError ? " tone-err" : " tone-ok";
328
+ return `<div class="tool-block" data-live-item>
329
+ <div class="tool-header">
330
+ <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>
331
+ <span class="tool-name">${esc(result.toolName)}</span>
332
+ </div>
333
+ <pre class="tool-output${toneClass}">${esc(result.result)}</pre>
334
+ </div>`;
335
+ }
336
+ function renderLiveSystemEvent(text, tone = "default") {
337
+ const cls = tone === "err" ? " system-event-err" : "";
338
+ return `<div class="system-event${cls}" data-live-item><span class="event-dot"></span><span class="event-text">${esc(text)}</span></div>`;
339
+ }
340
+ async function handleSessionStreamRequest(req, res, url, sessionViewTokenStore, interactive) {
341
+ const token = url.searchParams.get("token")?.trim() ?? "";
342
+ if (!token || !sessionViewTokenStore || !interactive) {
343
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
344
+ res.end("Session stream unavailable");
345
+ return;
346
+ }
347
+ const entry = sessionViewTokenStore.peek(token);
348
+ if (!entry) {
349
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
350
+ res.end("Invalid session token");
351
+ return;
352
+ }
353
+ const requestedSession = url.searchParams.get("session");
354
+ const targetSessionFile = resolveRequestedSessionFile(entry.sessionFile, requestedSession);
355
+ if (!targetSessionFile) {
356
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
357
+ res.end("Invalid session file");
358
+ return;
359
+ }
360
+ const activeSessionKey = resolveDisplayedSessionKey(entry, targetSessionFile);
361
+ const streamKey = sessionStreamKey({ ...entry, sessionKey: activeSessionKey });
362
+ res.writeHead(200, {
363
+ "Content-Type": "text/event-stream; charset=utf-8",
364
+ "Cache-Control": "no-store",
365
+ Connection: "keep-alive",
366
+ });
367
+ res.write(`data: ${JSON.stringify({ type: "status", running: interactive.handler.isRunning(activeSessionKey) })}\n\n`);
368
+ const unsubscribe = sessionViewStreamHub.subscribe(streamKey, (event) => {
369
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
370
+ });
371
+ const heartbeat = setInterval(() => {
372
+ res.write(": keep-alive\n\n");
373
+ }, 15000);
374
+ req.on("close", () => {
375
+ clearInterval(heartbeat);
376
+ unsubscribe();
377
+ });
378
+ }
379
+ async function handleSessionMessageRequest(req, res, sessionViewTokenStore, interactive) {
380
+ if (!sessionViewTokenStore || !interactive) {
381
+ json(res, 503, { ok: false, error: "Session chat is not configured." });
382
+ return;
383
+ }
384
+ let body;
385
+ try {
386
+ body = JSON.parse(await readRequestBody(req));
387
+ }
388
+ catch {
389
+ json(res, 400, { ok: false, error: "Invalid request body." });
390
+ return;
391
+ }
392
+ const token = body.token?.trim() ?? "";
393
+ const text = body.text?.trim() ?? "";
394
+ const requestedSession = body.session?.trim() || null;
395
+ const requestedSessionKey = body.sessionKey?.trim() || "";
396
+ if (!token || !text) {
397
+ json(res, 400, { ok: false, error: "Missing token or text." });
398
+ return;
399
+ }
400
+ const entry = sessionViewTokenStore.peek(token);
401
+ if (!entry) {
402
+ json(res, 400, { ok: false, error: "This session link is invalid or has expired." });
403
+ return;
404
+ }
405
+ const targetSessionFile = resolveRequestedSessionFile(entry.sessionFile, requestedSession);
406
+ if (!targetSessionFile) {
407
+ json(res, 400, { ok: false, error: "Invalid session file." });
408
+ return;
409
+ }
410
+ const activeSessionKey = resolveDisplayedSessionKey(entry, targetSessionFile);
411
+ if (requestedSessionKey && requestedSessionKey !== activeSessionKey) {
412
+ json(res, 400, { ok: false, error: "Session target mismatch." });
413
+ return;
414
+ }
415
+ const bot = interactive.botsByPlatform[entry.platform];
416
+ if (!bot) {
417
+ json(res, 503, { ok: false, error: `No bot configured for ${entry.platform}.` });
418
+ return;
419
+ }
420
+ const streamKey = sessionStreamKey({ ...entry, sessionKey: activeSessionKey });
421
+ const conversationKind = inferConversationKind(entry.platform, entry.conversationId);
422
+ const ts = (Date.now() / 1000).toFixed(6);
423
+ const platformInfo = bot.getPlatformInfo();
424
+ const platformUserName = entry.platformUserName ||
425
+ platformInfo.users.find((user) => user.id === entry.platformUserId)?.userName ||
426
+ platformInfo.users.find((user) => user.id === entry.platformUserId)?.displayName ||
427
+ "unknown";
428
+ const responseCtx = createSessionViewResponseContext((event) => {
429
+ sessionViewStreamHub.publish(streamKey, event);
430
+ });
431
+ const event = {
432
+ type: "session_view",
433
+ conversationId: entry.conversationId,
434
+ conversationKind,
435
+ ts,
436
+ user: entry.platformUserId,
437
+ text,
438
+ attachments: [],
439
+ sessionKey: activeSessionKey,
440
+ ...(activeSessionKey.includes(":")
441
+ ? { thread_ts: activeSessionKey.split(":").slice(1).join(":") }
442
+ : {}),
443
+ };
444
+ const adapters = {
445
+ message: {
446
+ id: ts,
447
+ sessionKey: activeSessionKey,
448
+ conversationKind,
449
+ userId: entry.platformUserId,
450
+ userName: platformUserName,
451
+ text,
452
+ attachments: [],
453
+ threadTs: event.thread_ts,
454
+ },
455
+ responseCtx,
456
+ platform: { ...platformInfo, diagnostics: { showUsageSummary: false } },
457
+ };
458
+ sessionViewStreamHub.publish(streamKey, { type: "status", running: true });
459
+ sessionViewStreamHub.publish(streamKey, {
460
+ type: "user",
461
+ html: renderLiveUserMessage(text, platformUserName),
462
+ });
463
+ void interactive.handler
464
+ .handleEvent(event, bot, adapters, false)
465
+ .then(() => {
466
+ if (!targetSessionFile) {
467
+ sessionViewStreamHub.publish(streamKey, { type: "status", running: false });
468
+ return;
469
+ }
470
+ const model = loadSessionViewModel(targetSessionFile);
471
+ sessionViewStreamHub.publish(streamKey, {
472
+ type: "refresh",
473
+ timelineHtml: renderTimelineItems(model.items, token),
474
+ updatedAt: formatDate(model.updatedAt),
475
+ entryCount: model.entryCount,
476
+ running: false,
477
+ });
478
+ })
479
+ .catch((error) => {
480
+ log.logWarning(`[${entry.conversationId}] Session view message failed`, error instanceof Error ? error.message : String(error));
481
+ sessionViewStreamHub.publish(streamKey, {
482
+ type: "error",
483
+ message: error instanceof Error ? error.message : String(error),
484
+ });
485
+ sessionViewStreamHub.publish(streamKey, { type: "status", running: false });
486
+ });
487
+ json(res, 202, { ok: true, accepted: true });
488
+ }
489
+ function createSessionViewResponseContext(publish) {
490
+ let accumulatedText = "";
491
+ return {
492
+ respond: async (text) => {
493
+ accumulatedText = accumulatedText ? `${accumulatedText}\n${text}` : text;
494
+ publish({ type: "assistant", html: renderLiveAssistantMessage(accumulatedText) });
495
+ },
496
+ replaceResponse: async (text) => {
497
+ accumulatedText = text;
498
+ publish({ type: "assistant", html: renderLiveAssistantMessage(accumulatedText) });
499
+ },
500
+ respondDiagnostic: async (text, options) => {
501
+ if (options?.style === "error") {
502
+ publish({ type: "system", html: renderLiveSystemEvent(text, "err") });
503
+ }
504
+ },
505
+ respondToolResult: async (result) => {
506
+ publish({ type: "tool", html: renderLiveToolResult(result) });
507
+ },
508
+ setTyping: async () => {
509
+ publish({ type: "status", running: true });
510
+ },
511
+ setWorking: async (working) => {
512
+ publish({ type: "status", running: working });
513
+ },
514
+ uploadFile: async () => { },
515
+ deleteResponse: async () => {
516
+ accumulatedText = "";
517
+ publish({ type: "assistant_remove" });
518
+ },
519
+ };
520
+ }
521
+ function readRequestBody(req) {
522
+ return new Promise((resolve, reject) => {
523
+ let data = "";
524
+ req.setEncoding("utf8");
525
+ req.on("data", (chunk) => {
526
+ data += chunk;
527
+ if (data.length > 1024 * 1024) {
528
+ reject(new Error("Request body too large"));
529
+ req.destroy();
530
+ }
531
+ });
532
+ req.on("end", () => resolve(data));
533
+ req.on("error", reject);
534
+ });
535
+ }
536
+ function json(res, status, body) {
537
+ res.writeHead(status, {
538
+ "Content-Type": "application/json; charset=utf-8",
539
+ "Cache-Control": "no-store",
540
+ });
541
+ res.end(JSON.stringify(body));
542
+ }
203
543
  function renderStatusPage(title, message) {
204
544
  return renderHtmlDocument(title, `<section class="card stack">
205
545
  <p class="eyebrow">mama</p>
206
546
  <h1>${esc(title)}</h1>
207
547
  <div class="status err">${esc(message)}</div>
208
- </section>`);
548
+ </section>`, false);
209
549
  }
210
- function renderHtmlDocument(title, shellContent) {
550
+ function renderHtmlDocument(title, shellContent, isRunning) {
211
551
  return `<!DOCTYPE html>
212
552
  <html lang="en">
213
553
  <head>
@@ -216,10 +556,179 @@ function renderHtmlDocument(title, shellContent) {
216
556
  <title>${esc(title)}</title>
217
557
  <style>${styles}</style>
218
558
  </head>
219
- <body>
559
+ <body data-session-running="${isRunning ? "true" : "false"}">
220
560
  <main class="shell">
221
561
  ${shellContent}
222
562
  </main>
563
+ <script>
564
+ const form = document.querySelector('[data-session-composer]');
565
+ const timelineList = document.querySelector('[data-timeline-list]');
566
+ const jumpLatestBtn = document.querySelector('[data-jump-latest]');
567
+ const statusEl = document.querySelector('[data-session-status]');
568
+ const updatedEl = document.querySelector('[data-session-updated]');
569
+ const entriesEl = document.querySelector('[data-session-entries]');
570
+ const composerStatus = form?.querySelector('[data-composer-status]');
571
+ const textarea = form?.querySelector('textarea[name="text"]');
572
+ const submitButton = form?.querySelector('button[type="submit"]');
573
+ let liveAssistant = null;
574
+ let running = document.body.dataset.sessionRunning === 'true';
575
+
576
+ const isNearBottom = () => window.innerHeight + window.scrollY >= document.body.offsetHeight - 120;
577
+ const scrollToLatest = (behavior = 'smooth') => window.scrollTo({ top: document.body.scrollHeight, behavior });
578
+ const toggleJumpButton = () => {
579
+ if (!jumpLatestBtn) return;
580
+ jumpLatestBtn.hidden = isNearBottom();
581
+ };
582
+ const updateFollowState = () => {
583
+ if (isNearBottom()) scrollToLatest('smooth');
584
+ else toggleJumpButton();
585
+ };
586
+ const canSubmit = () => Boolean(textarea && textarea.value.trim()) && !running;
587
+ const updateSubmitButtonState = () => {
588
+ if (submitButton) submitButton.disabled = !canSubmit();
589
+ };
590
+ const setRunning = (value) => {
591
+ running = value;
592
+ document.body.dataset.sessionRunning = value ? 'true' : 'false';
593
+ if (statusEl) statusEl.textContent = value ? 'Running' : 'Idle';
594
+ updateSubmitButtonState();
595
+ if (composerStatus && !value && composerStatus.textContent === 'Thinking…') {
596
+ composerStatus.textContent = '';
597
+ }
598
+ };
599
+
600
+ jumpLatestBtn?.addEventListener('click', () => {
601
+ scrollToLatest('smooth');
602
+ toggleJumpButton();
603
+ });
604
+ document.addEventListener('click', async (event) => {
605
+ const button = event.target instanceof Element ? event.target.closest('[data-copy-button]') : null;
606
+ if (!(button instanceof HTMLButtonElement)) return;
607
+ const label = button.dataset.copyLabel || 'Copy message';
608
+ const source = button.closest('.msg-actions')?.previousElementSibling;
609
+ const text = source instanceof HTMLElement ? (source.innerText || source.textContent || '').trim() : '';
610
+ if (!text) return;
611
+ const setState = (state, transient) => {
612
+ button.dataset.copyState = state;
613
+ button.title = transient;
614
+ button.setAttribute('aria-label', transient);
615
+ window.setTimeout(() => {
616
+ if (!button.isConnected) return;
617
+ delete button.dataset.copyState;
618
+ button.title = label;
619
+ button.setAttribute('aria-label', label);
620
+ }, 1200);
621
+ };
622
+ try {
623
+ await navigator.clipboard.writeText(text);
624
+ setState('done', 'Copied');
625
+ } catch {
626
+ setState('error', 'Copy failed');
627
+ }
628
+ });
629
+ window.addEventListener('scroll', toggleJumpButton, { passive: true });
630
+
631
+ if (textarea) {
632
+ const resize = () => {
633
+ textarea.style.height = 'auto';
634
+ textarea.style.height = Math.min(textarea.scrollHeight, 240) + 'px';
635
+ };
636
+ textarea.addEventListener('input', () => {
637
+ resize();
638
+ updateSubmitButtonState();
639
+ });
640
+ textarea.addEventListener('keydown', (event) => {
641
+ if (event.key !== 'Enter' || event.shiftKey) return;
642
+ if (event.isComposing || event.keyCode === 229) return;
643
+ event.preventDefault();
644
+ if (!running) form?.requestSubmit();
645
+ });
646
+ resize();
647
+ }
648
+
649
+ setRunning(running);
650
+ updateSubmitButtonState();
651
+
652
+ const streamUrl = form
653
+ ? '/session/stream?token=' + encodeURIComponent(form.token.value) + '&session=' + encodeURIComponent(form.session.value)
654
+ : null;
655
+ if (streamUrl) {
656
+ const source = new EventSource(streamUrl);
657
+ source.onmessage = (event) => {
658
+ const payload = JSON.parse(event.data);
659
+ switch (payload.type) {
660
+ case 'status':
661
+ setRunning(Boolean(payload.running));
662
+ if (payload.running && composerStatus) composerStatus.textContent = 'Thinking…';
663
+ break;
664
+ case 'user':
665
+ case 'tool':
666
+ case 'system': {
667
+ timelineList?.insertAdjacentHTML('beforeend', payload.html);
668
+ updateFollowState();
669
+ break;
670
+ }
671
+ case 'assistant': {
672
+ if (!liveAssistant || !liveAssistant.isConnected) {
673
+ timelineList?.insertAdjacentHTML('beforeend', payload.html);
674
+ liveAssistant = timelineList?.querySelector('[data-live-assistant]:last-of-type') || null;
675
+ } else {
676
+ liveAssistant.outerHTML = payload.html;
677
+ liveAssistant = timelineList?.querySelector('[data-live-assistant]:last-of-type') || null;
678
+ }
679
+ updateFollowState();
680
+ break;
681
+ }
682
+ case 'assistant_remove':
683
+ if (liveAssistant?.isConnected) liveAssistant.remove();
684
+ liveAssistant = null;
685
+ break;
686
+ case 'refresh':
687
+ if (timelineList) timelineList.innerHTML = payload.timelineHtml;
688
+ liveAssistant = null;
689
+ if (updatedEl) updatedEl.textContent = payload.updatedAt;
690
+ if (entriesEl) entriesEl.textContent = String(payload.entryCount);
691
+ setRunning(Boolean(payload.running));
692
+ if (composerStatus) composerStatus.textContent = '';
693
+ updateFollowState();
694
+ break;
695
+ case 'error':
696
+ if (composerStatus) composerStatus.textContent = payload.message || 'Something went wrong';
697
+ setRunning(false);
698
+ break;
699
+ }
700
+ };
701
+ }
702
+
703
+ form?.addEventListener('submit', async (event) => {
704
+ event.preventDefault();
705
+ if (!textarea || !composerStatus) return;
706
+ const text = textarea.value.trim();
707
+ if (!text || running) return;
708
+ composerStatus.textContent = 'Sending…';
709
+ updateSubmitButtonState();
710
+ try {
711
+ const response = await fetch('/session/message', {
712
+ method: 'POST',
713
+ headers: { 'Content-Type': 'application/json' },
714
+ body: JSON.stringify({ token: form.token.value, session: form.session.value, sessionKey: form.sessionKey.value, text }),
715
+ });
716
+ const payload = await response.json();
717
+ if (!response.ok || !payload.ok) throw new Error(payload.error || 'Request failed');
718
+ textarea.value = '';
719
+ textarea.style.height = 'auto';
720
+ composerStatus.textContent = 'Thinking…';
721
+ setRunning(true);
722
+ updateSubmitButtonState();
723
+ scrollToLatest('smooth');
724
+ } catch (err) {
725
+ composerStatus.textContent = err && err.message ? err.message : String(err);
726
+ submitButton.disabled = false;
727
+ }
728
+ });
729
+
730
+ toggleJumpButton();
731
+ </script>
223
732
  </body>
224
733
  </html>`;
225
734
  }
@@ -274,10 +783,11 @@ const styles = `
274
783
 
275
784
  body {
276
785
  min-height: 100vh;
277
- padding: 40px 20px 80px;
786
+ padding: 40px 20px calc(140px + env(safe-area-inset-bottom, 0px));
278
787
  display: flex;
279
788
  flex-direction: column;
280
789
  align-items: center;
790
+ overflow-x: hidden;
281
791
  background-color: var(--bg);
282
792
  background-image:
283
793
  radial-gradient(ellipse 80% 40% at 50% -10%, rgba(255,255,255,0.6) 0%, transparent 70%);
@@ -291,6 +801,7 @@ const styles = `
291
801
  .shell {
292
802
  width: 100%;
293
803
  max-width: 780px;
804
+ min-width: 0;
294
805
  display: flex;
295
806
  flex-direction: column;
296
807
  gap: 12px;
@@ -310,8 +821,8 @@ const styles = `
310
821
  display: flex;
311
822
  align-items: flex-start;
312
823
  justify-content: space-between;
313
- gap: 16px;
314
- margin-bottom: 20px;
824
+ gap: 20px;
825
+ margin-bottom: 18px;
315
826
  }
316
827
 
317
828
  .hero-wordmark {
@@ -332,61 +843,103 @@ const styles = `
332
843
  letter-spacing: -0.01em;
333
844
  color: var(--text);
334
845
  text-wrap: balance;
846
+ margin-bottom: 8px;
847
+ }
848
+
849
+ .hero-meta-line {
850
+ display: flex;
851
+ flex-wrap: wrap;
852
+ gap: 8px 14px;
853
+ color: var(--muted);
854
+ font-size: 0.82rem;
855
+ line-height: 1.4;
856
+ }
857
+
858
+ .hero-meta-line strong {
859
+ color: var(--text);
860
+ font-weight: 600;
861
+ }
862
+
863
+ .hero-side {
864
+ display: flex;
865
+ flex-direction: column;
866
+ align-items: flex-end;
867
+ gap: 8px;
868
+ flex-shrink: 0;
335
869
  }
336
870
 
337
- .refresh-btn {
871
+ .hero-badge {
338
872
  display: inline-flex;
339
873
  align-items: center;
340
- gap: 6px;
341
- flex-shrink: 0;
342
- padding: 7px 14px;
874
+ gap: 8px;
875
+ padding: 6px 11px;
343
876
  border: 1px solid var(--border);
344
877
  border-radius: 999px;
345
- background: transparent;
878
+ background: rgba(255,255,255,0.7);
879
+ font-size: 0.78rem;
346
880
  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;
881
+ line-height: 1;
351
882
  }
352
883
 
353
- .refresh-btn:hover {
884
+ .hero-badge strong {
354
885
  color: var(--text);
355
- border-color: rgba(0,0,0,0.2);
356
- background: rgba(0,0,0,0.03);
886
+ font-weight: 600;
887
+ }
888
+
889
+ .hero-badge-status.is-running {
890
+ background: #fff7ed;
891
+ border-color: rgba(217, 119, 6, 0.18);
892
+ color: #9a3412;
893
+ }
894
+
895
+ .hero-badge-dot {
896
+ width: 7px;
897
+ height: 7px;
898
+ border-radius: 50%;
899
+ background: #a1a1aa;
900
+ flex-shrink: 0;
357
901
  }
358
902
 
359
- .refresh-btn:focus-visible {
360
- outline: 2px solid var(--text);
361
- outline-offset: 2px;
903
+ .hero-badge-status.is-running .hero-badge-dot {
904
+ background: #d97706;
905
+ box-shadow: 0 0 0 4px rgba(217, 119, 6, 0.14);
362
906
  }
363
907
 
364
- .stat-row {
908
+ .hero-detail-row {
365
909
  display: flex;
366
910
  flex-wrap: wrap;
367
- gap: 6px;
911
+ gap: 10px;
912
+ padding-top: 14px;
913
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
368
914
  }
369
915
 
370
- .stat-chip {
916
+ .hero-detail {
371
917
  display: inline-flex;
372
918
  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;
919
+ gap: 8px;
920
+ min-width: 0;
921
+ padding: 6px 10px;
922
+ border-radius: 12px;
923
+ background: rgba(0, 0, 0, 0.025);
924
+ color: var(--muted);
925
+ font-size: 0.78rem;
380
926
  }
381
927
 
382
- .stat-label {
383
- color: var(--muted);
384
- font-weight: 500;
928
+ .hero-detail-label {
929
+ text-transform: uppercase;
930
+ letter-spacing: 0.08em;
931
+ font-size: 0.68rem;
932
+ color: var(--subtle);
385
933
  }
386
934
 
387
- .stat-value {
935
+ .hero-detail code {
936
+ min-width: 0;
937
+ overflow: hidden;
938
+ text-overflow: ellipsis;
939
+ white-space: nowrap;
940
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
941
+ font-size: 0.74rem;
388
942
  color: var(--text);
389
- font-weight: 600;
390
943
  }
391
944
 
392
945
  /* ── Timeline shell ───────────────────────────────────────────────────── */
@@ -507,7 +1060,100 @@ const styles = `
507
1060
  .timeline-list {
508
1061
  display: flex;
509
1062
  flex-direction: column;
510
- gap: 4px;
1063
+ gap: 14px;
1064
+ min-width: 0;
1065
+ }
1066
+
1067
+ .copy-host {
1068
+ position: relative;
1069
+ }
1070
+
1071
+ .msg-actions {
1072
+ height: 32px;
1073
+ display: flex;
1074
+ align-items: center;
1075
+ gap: 8px;
1076
+ margin-top: 8px;
1077
+ opacity: 0;
1078
+ visibility: hidden;
1079
+ transition: opacity 140ms ease, visibility 140ms ease;
1080
+ }
1081
+
1082
+ .copy-host:hover .msg-actions,
1083
+ .copy-host .msg-actions:hover,
1084
+ .copy-host:focus-within .msg-actions,
1085
+ .timeline-list > .copy-host:last-child .msg-actions,
1086
+ .copy-action-btn[data-copy-state] {
1087
+ opacity: 1;
1088
+ visibility: visible;
1089
+ }
1090
+
1091
+ .copy-action-btn {
1092
+ width: 24px;
1093
+ height: 24px;
1094
+ display: inline-flex;
1095
+ align-items: center;
1096
+ justify-content: center;
1097
+ border: 0;
1098
+ border-radius: 0;
1099
+ background: transparent;
1100
+ color: rgba(63,63,70,0.8);
1101
+ transition: color 140ms ease, opacity 140ms ease;
1102
+ cursor: pointer;
1103
+ padding: 0;
1104
+ appearance: none;
1105
+ }
1106
+
1107
+ .copy-action-btn:hover {
1108
+ background: transparent;
1109
+ color: rgba(24,24,27,0.96);
1110
+ border-color: transparent;
1111
+ }
1112
+
1113
+ .copy-action-btn[data-copy-state='done'] {
1114
+ background: transparent;
1115
+ border-color: transparent;
1116
+ color: rgba(24,24,27,0.96);
1117
+ }
1118
+
1119
+ .copy-action-btn[data-copy-state='done'] svg {
1120
+ position: absolute;
1121
+ opacity: 0;
1122
+ transform: scale(0.6);
1123
+ pointer-events: none;
1124
+ }
1125
+
1126
+ .copy-action-btn svg {
1127
+ transition: opacity 140ms ease, transform 140ms ease;
1128
+ }
1129
+
1130
+ .copy-action-btn[data-copy-state='done']::before {
1131
+ content: '';
1132
+ width: 14px;
1133
+ height: 14px;
1134
+ background-color: currentColor;
1135
+ -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='4 12 10 18 20 6'/></svg>") center / contain no-repeat;
1136
+ mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='4 12 10 18 20 6'/></svg>") center / contain no-repeat;
1137
+ animation: copy-check-in 200ms ease-out both;
1138
+ }
1139
+
1140
+ @keyframes copy-check-in {
1141
+ from { opacity: 0; transform: scale(0.6); }
1142
+ to { opacity: 1; transform: scale(1); }
1143
+ }
1144
+
1145
+ @media (prefers-reduced-motion: reduce) {
1146
+ .copy-action-btn svg,
1147
+ .copy-action-btn[data-copy-state='done']::before {
1148
+ transition: none;
1149
+ animation: none;
1150
+ }
1151
+ }
1152
+
1153
+ .copy-action-btn[data-copy-state='error'] {
1154
+ background: transparent;
1155
+ border-color: transparent;
1156
+ color: #b91c1c;
511
1157
  }
512
1158
 
513
1159
  /* ── Message rows ─────────────────────────────────────────────────────── */
@@ -516,7 +1162,8 @@ const styles = `
516
1162
  display: flex;
517
1163
  align-items: flex-end;
518
1164
  gap: 8px;
519
- padding: 2px 0;
1165
+ padding: 4px 0;
1166
+ min-width: 0;
520
1167
  }
521
1168
 
522
1169
  /* ── User messages ────────────────────────────────────────────────────── */
@@ -525,8 +1172,20 @@ const styles = `
525
1172
  justify-content: flex-end;
526
1173
  }
527
1174
 
528
- .user-bubble {
1175
+ .msg-main {
1176
+ min-width: 0;
1177
+ }
1178
+
1179
+ .user-main {
529
1180
  max-width: 85%;
1181
+ display: flex;
1182
+ flex-direction: column;
1183
+ align-items: flex-end;
1184
+ }
1185
+
1186
+ .user-bubble {
1187
+ max-width: 100%;
1188
+ min-width: 0;
530
1189
  padding: 12px 16px;
531
1190
  border-radius: 18px 18px 4px 18px;
532
1191
  background: var(--user-bg);
@@ -568,11 +1227,6 @@ const styles = `
568
1227
  }
569
1228
 
570
1229
  .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
1230
  color: var(--user-text);
577
1231
  }
578
1232
 
@@ -619,10 +1273,19 @@ const styles = `
619
1273
  align-items: flex-end;
620
1274
  gap: 8px;
621
1275
  max-width: 85%;
1276
+ min-width: 0;
1277
+ }
1278
+
1279
+ .asst-main {
1280
+ max-width: 100%;
1281
+ display: flex;
1282
+ flex-direction: column;
1283
+ align-items: flex-start;
622
1284
  }
623
1285
 
624
1286
  .asst-card {
625
1287
  min-width: 0;
1288
+ max-width: 100%;
626
1289
  padding: 14px 18px;
627
1290
  border: 1px solid var(--border);
628
1291
  border-radius: 18px 18px 18px 4px;
@@ -631,11 +1294,6 @@ const styles = `
631
1294
  }
632
1295
 
633
1296
  .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
1297
  color: var(--text);
640
1298
  }
641
1299
 
@@ -655,7 +1313,7 @@ const styles = `
655
1313
  overflow: hidden;
656
1314
  border: 1px solid rgba(255,255,255,0.06);
657
1315
  box-shadow: 0 2px 8px rgba(0,0,0,0.16);
658
- margin: 6px 0;
1316
+ margin: 2px 0;
659
1317
  }
660
1318
 
661
1319
  .tool-header {
@@ -711,6 +1369,143 @@ const styles = `
711
1369
  .tool-output.tone-ok { color: var(--tool-ok); }
712
1370
  .tool-output.tone-err { color: var(--tool-err); }
713
1371
 
1372
+ /* ── Markdown blocks ──────────────────────────────────────────────────── */
1373
+
1374
+ .markdown-body {
1375
+ font-family: 'DM Sans', system-ui, sans-serif;
1376
+ font-size: 0.9rem;
1377
+ line-height: 1.65;
1378
+ word-break: break-word;
1379
+ }
1380
+
1381
+ .markdown-body > *:first-child { margin-top: 0; }
1382
+ .markdown-body > *:last-child { margin-bottom: 0; }
1383
+ .markdown-body p,
1384
+ .markdown-body ul,
1385
+ .markdown-body ol,
1386
+ .markdown-body blockquote,
1387
+ .markdown-body pre,
1388
+ .markdown-body table,
1389
+ .markdown-body hr {
1390
+ margin: 0 0 0.85em;
1391
+ }
1392
+
1393
+ .markdown-body h1,
1394
+ .markdown-body h2,
1395
+ .markdown-body h3,
1396
+ .markdown-body h4,
1397
+ .markdown-body h5,
1398
+ .markdown-body h6 {
1399
+ margin: 0 0 0.55em;
1400
+ line-height: 1.25;
1401
+ font-weight: 700;
1402
+ letter-spacing: -0.01em;
1403
+ }
1404
+
1405
+ .markdown-body h1 { font-size: 1.4rem; }
1406
+ .markdown-body h2 { font-size: 1.22rem; }
1407
+ .markdown-body h3 { font-size: 1.08rem; }
1408
+ .markdown-body h4,
1409
+ .markdown-body h5,
1410
+ .markdown-body h6 { font-size: 0.95rem; }
1411
+
1412
+ .markdown-body ul,
1413
+ .markdown-body ol {
1414
+ padding-left: 1.3em;
1415
+ }
1416
+
1417
+ .markdown-body li + li {
1418
+ margin-top: 0.22em;
1419
+ }
1420
+
1421
+ .markdown-body blockquote {
1422
+ padding-left: 12px;
1423
+ border-left: 3px solid rgba(34, 197, 94, 0.35);
1424
+ opacity: 0.95;
1425
+ }
1426
+
1427
+ .markdown-body a {
1428
+ color: inherit;
1429
+ text-decoration: underline;
1430
+ text-underline-offset: 2px;
1431
+ }
1432
+
1433
+ .markdown-body code {
1434
+ font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
1435
+ font-size: 0.82em;
1436
+ padding: 0.16em 0.38em;
1437
+ border-radius: 6px;
1438
+ }
1439
+
1440
+ .markdown-body pre {
1441
+ overflow-x: auto;
1442
+ border-radius: 12px;
1443
+ padding: 12px 14px;
1444
+ }
1445
+
1446
+ .markdown-body pre code {
1447
+ display: block;
1448
+ padding: 0;
1449
+ border-radius: 0;
1450
+ background: transparent;
1451
+ font-size: 0.82rem;
1452
+ line-height: 1.6;
1453
+ }
1454
+
1455
+ .markdown-body table {
1456
+ width: 100%;
1457
+ border-collapse: collapse;
1458
+ font-size: 0.85rem;
1459
+ }
1460
+
1461
+ .markdown-body th,
1462
+ .markdown-body td {
1463
+ padding: 8px 10px;
1464
+ border: 1px solid rgba(0, 0, 0, 0.08);
1465
+ text-align: left;
1466
+ vertical-align: top;
1467
+ }
1468
+
1469
+ .markdown-body img {
1470
+ max-width: 100%;
1471
+ border-radius: 12px;
1472
+ }
1473
+
1474
+ .markdown-user code {
1475
+ background: rgba(255,255,255,0.14);
1476
+ color: var(--user-text);
1477
+ }
1478
+
1479
+ .markdown-user pre {
1480
+ background: rgba(255,255,255,0.08);
1481
+ border: 1px solid rgba(255,255,255,0.08);
1482
+ }
1483
+
1484
+ .markdown-user table th,
1485
+ .markdown-user table td {
1486
+ border-color: rgba(255,255,255,0.16);
1487
+ }
1488
+
1489
+ .markdown-assistant code {
1490
+ background: #f4f4f5;
1491
+ color: #27272a;
1492
+ }
1493
+
1494
+ .markdown-assistant pre {
1495
+ background: #0f172a;
1496
+ color: #e5e7eb;
1497
+ }
1498
+
1499
+ .markdown-assistant pre code {
1500
+ background: transparent;
1501
+ color: inherit;
1502
+ }
1503
+
1504
+ .markdown-assistant table th,
1505
+ .markdown-assistant table td {
1506
+ border-color: rgba(0, 0, 0, 0.08);
1507
+ }
1508
+
714
1509
  /* ── System events ────────────────────────────────────────────────────── */
715
1510
 
716
1511
  .system-event {
@@ -736,6 +1531,10 @@ const styles = `
736
1531
  color: var(--muted);
737
1532
  }
738
1533
 
1534
+ .system-event-err .event-text {
1535
+ color: var(--err-text);
1536
+ }
1537
+
739
1538
  .event-time {
740
1539
  color: var(--subtle);
741
1540
  font-style: normal;
@@ -783,18 +1582,157 @@ const styles = `
783
1582
  border: 1px solid rgba(185, 28, 28, 0.12);
784
1583
  }
785
1584
 
1585
+ /* ── Composer ─────────────────────────────────────────────────────────── */
1586
+
1587
+ .composer-card {
1588
+ position: fixed;
1589
+ left: 50%;
1590
+ bottom: calc(16px + env(safe-area-inset-bottom, 0px));
1591
+ transform: translateX(-50%);
1592
+ width: calc(100% - 32px);
1593
+ max-width: 780px;
1594
+ padding: 10px 12px 10px 14px;
1595
+ border: 1px solid var(--border);
1596
+ border-radius: 22px;
1597
+ background: rgba(250, 248, 244, 0.92);
1598
+ box-shadow: 0 12px 36px rgba(0,0,0,0.10), 0 2px 6px rgba(0,0,0,0.04);
1599
+ backdrop-filter: blur(14px);
1600
+ -webkit-backdrop-filter: blur(14px);
1601
+ z-index: 20;
1602
+ }
1603
+
1604
+ .composer-card .composer-copy { display: none; }
1605
+
1606
+ .composer-form {
1607
+ display: flex;
1608
+ flex-direction: column;
1609
+ gap: 6px;
1610
+ }
1611
+
1612
+ .jump-latest-btn {
1613
+ position: fixed;
1614
+ left: 50%;
1615
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 120px);
1616
+ z-index: 25;
1617
+ width: 42px;
1618
+ height: 42px;
1619
+ border: 1px solid var(--border);
1620
+ border-radius: 999px;
1621
+ background: var(--bg);
1622
+ color: var(--text);
1623
+ font: 700 1rem/1 'DM Sans', sans-serif;
1624
+ box-shadow: 0 10px 30px rgba(0,0,0,0.12);
1625
+ cursor: pointer;
1626
+ backdrop-filter: blur(10px);
1627
+ transform: translateX(-50%);
1628
+ outline: none;
1629
+ appearance: none;
1630
+ -webkit-tap-highlight-color: transparent;
1631
+ }
1632
+
1633
+ .jump-latest-btn:hover {
1634
+ transform: translateX(-50%) translateY(-1px);
1635
+ background: #e8e3d9;
1636
+ }
1637
+
1638
+ .jump-latest-btn:focus,
1639
+ .jump-latest-btn:active {
1640
+ outline: none;
1641
+ }
1642
+
1643
+ .jump-latest-btn:focus-visible {
1644
+ box-shadow: 0 10px 30px rgba(0,0,0,0.12), 0 0 0 3px rgba(0,0,0,0.08);
1645
+ }
1646
+
1647
+ .composer-copy { margin-bottom: 12px; color: var(--muted); }
1648
+
1649
+ .composer-form textarea {
1650
+ width: 100%;
1651
+ resize: none;
1652
+ overflow-y: auto;
1653
+ min-height: 28px;
1654
+ max-height: 200px;
1655
+ padding: 6px 6px 2px;
1656
+ border: 0;
1657
+ border-radius: 0;
1658
+ font: inherit;
1659
+ color: var(--text);
1660
+ background: transparent;
1661
+ }
1662
+
1663
+ .composer-form textarea::placeholder {
1664
+ color: rgba(63,63,70,0.55);
1665
+ }
1666
+
1667
+ .composer-form textarea:focus {
1668
+ outline: none;
1669
+ border: 0;
1670
+ }
1671
+
1672
+ .composer-actions {
1673
+ display: flex;
1674
+ align-items: center;
1675
+ justify-content: space-between;
1676
+ gap: 12px;
1677
+ margin-top: 0;
1678
+ }
1679
+
1680
+ .composer-status { color: var(--muted); font-size: 13px; }
1681
+ .composer-actions button:disabled { opacity: 0.55; cursor: wait; }
1682
+
1683
+ .composer-send-btn {
1684
+ display: inline-flex;
1685
+ align-items: center;
1686
+ justify-content: center;
1687
+ width: 32px;
1688
+ height: 32px;
1689
+ border: none;
1690
+ border-radius: 999px;
1691
+ background: #d97706;
1692
+ color: #ffffff;
1693
+ font: 700 1rem/1 'DM Sans', sans-serif;
1694
+ cursor: pointer;
1695
+ box-shadow: 0 10px 24px rgba(217, 119, 6, 0.26);
1696
+ transition: transform 120ms, filter 120ms, box-shadow 120ms, background 120ms;
1697
+ }
1698
+
1699
+ .composer-send-btn:hover:not(:disabled) {
1700
+ transform: translateY(-1px);
1701
+ filter: saturate(1.06) brightness(0.98);
1702
+ box-shadow: 0 12px 28px rgba(217, 119, 6, 0.32);
1703
+ }
1704
+
1705
+ .composer-send-btn:focus-visible {
1706
+ outline: 2px solid rgba(217, 119, 6, 0.28);
1707
+ outline-offset: 3px;
1708
+ }
1709
+
1710
+ .composer-send-btn:disabled {
1711
+ background: #d4d4d8;
1712
+ color: rgba(24, 24, 27, 0.45);
1713
+ box-shadow: none;
1714
+ transform: none;
1715
+ filter: none;
1716
+ cursor: not-allowed;
1717
+ opacity: 1;
1718
+ }
1719
+
786
1720
  /* ── Responsive ───────────────────────────────────────────────────────── */
787
1721
 
788
1722
  @media (max-width: 600px) {
789
- body { padding: 20px 12px 60px; }
1723
+ body { padding: 20px 12px calc(130px + env(safe-area-inset-bottom, 0px)); }
1724
+
1725
+ .composer-card { width: calc(100% - 16px); bottom: calc(8px + env(safe-area-inset-bottom, 0px)); padding: 8px 10px; border-radius: 18px; }
790
1726
 
791
1727
  .hero-card, .card { padding: 20px; border-radius: 16px; }
792
1728
 
793
1729
  .hero-top { flex-direction: column; gap: 12px; }
1730
+ .hero-side { align-items: flex-start; }
1731
+ .hero-detail-row { gap: 8px; }
794
1732
 
795
- .refresh-btn { align-self: flex-start; }
796
-
797
- .user-bubble { max-width: 88%; }
1733
+ .user-bubble,
1734
+ .msg-assistant,
1735
+ .tool-block { max-width: 100%; }
798
1736
 
799
1737
  .asst-avatar { display: none; }
800
1738