claude-simple 0.2.3 → 0.2.5

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.
@@ -14,12 +14,14 @@ import {
14
14
  refreshMachinesList, hideMachinePicker, showMachinePicker,
15
15
  syncTopbarMachine, startMachinePolling,
16
16
  } from './hub.js';
17
+ import { syncFocusScope } from '../mobile.js';
17
18
 
18
19
  export async function showApp(opts = {}) {
19
20
  $('auth-screen').style.display = 'none';
20
21
  const app = $('app');
21
22
  app.classList.remove('hidden');
22
23
  app.style.display = 'flex';
24
+ queueMicrotask(syncFocusScope);
23
25
 
24
26
  // Use probe result from boot when provided (avoid second /api/hub round-trip)
25
27
  let hub = opts.hub;
@@ -8,6 +8,7 @@ import { api, probeHub } from '../api.js';
8
8
  import { loadAllSessions, loadWorkspaces, loadAgentsMeta } from './session-list.js';
9
9
  import { loadSessionMetaMap } from './notes.js';
10
10
  import { loadMetaAgentBoot } from '../agent-panel.js';
11
+ import { syncFocusScope } from '../mobile.js';
11
12
  // goHome imported dynamically in enterMachine to avoid circular deps
12
13
 
13
14
  export async function refreshMachinesList() {
@@ -54,6 +55,9 @@ export function showMachinePicker() {
54
55
  const el = $('machine-picker');
55
56
  if (!el) return;
56
57
  el.classList.remove('hidden');
58
+ // On phones hidden panels are inert so Safari's form assistant cannot tab
59
+ // into them. Re-enable this picker before its machine buttons are shown.
60
+ syncFocusScope();
57
61
  hubMachineReady.value = false;
58
62
  renderMachinePickerList();
59
63
  const status = $('machine-picker-status');
@@ -65,6 +69,7 @@ export function showMachinePicker() {
65
69
 
66
70
  export function hideMachinePicker() {
67
71
  $('machine-picker')?.classList.add('hidden');
72
+ syncFocusScope();
68
73
  hubMachineReady.value = true;
69
74
  }
70
75
 
@@ -191,4 +196,3 @@ export async function enterMachine(machineId, { forceReload = false } = {}) {
191
196
  await Promise.all([loadAllSessions(), loadWorkspaces(), loadSessionMetaMap(), loadAgentsMeta().catch(() => {})]);
192
197
  hubMachineReady.value = true;
193
198
  }
194
-
@@ -12,10 +12,19 @@ import { connectWS, clearMessages, appendSystemMsg, applyChatDensity } from '../
12
12
  import { refreshModelSelect } from './session-list.js';
13
13
  import { switchTab } from './session-nav.js';
14
14
  import { showMachinePicker } from './hub.js';
15
+ import { syncFocusScope } from '../mobile.js';
15
16
 
16
17
  // ── Sidebar ───────────────────────────────────────────────────────────────────
17
- export function openSidebar() { $('sidebar').classList.add('open'); $('sidebar-overlay').classList.remove('hidden'); }
18
- export function closeSidebar() { $('sidebar').classList.remove('open'); $('sidebar-overlay').classList.add('hidden'); }
18
+ export function openSidebar() {
19
+ $('sidebar').classList.add('open');
20
+ $('sidebar-overlay').classList.remove('hidden');
21
+ syncFocusScope();
22
+ }
23
+ export function closeSidebar() {
24
+ $('sidebar').classList.remove('open');
25
+ $('sidebar-overlay').classList.add('hidden');
26
+ syncFocusScope();
27
+ }
19
28
  export function closeSidebarOnMobile() { if (window.innerWidth <= 640) closeSidebar(); }
20
29
 
21
30
  export function initSidebarToggle() {
@@ -7,6 +7,7 @@ export const AuthScreen = () => `
7
7
  <div class="card-body gap-3 p-8">
8
8
  <h1 class="text-xl font-bold">🤖 Agent UI</h1>
9
9
  <p id="auth-subtitle" class="text-sm text-base-content/60 mb-1">Claude · Codex · Grok</p>
10
+ <form id="auth-form" class="flex flex-col gap-3" autocomplete="on" action="#">
10
11
  <label class="form-control">
11
12
  <div class="label py-1"><span class="label-text text-xs uppercase tracking-wide">Username</span></div>
12
13
  <input id="auth-username" type="text" class="input input-bordered input-sm" placeholder="username" autocomplete="username">
@@ -15,7 +16,8 @@ export const AuthScreen = () => `
15
16
  <div class="label py-1"><span class="label-text text-xs uppercase tracking-wide">Password</span></div>
16
17
  <input id="auth-password" type="password" class="input input-bordered input-sm" placeholder="••••••" autocomplete="current-password">
17
18
  </label>
18
- <button id="auth-btn" class="btn btn-primary btn-sm mt-1" data-mode="login">Sign In</button>
19
+ <button type="submit" id="auth-btn" class="btn btn-primary btn-sm mt-1" data-mode="login">Sign In</button>
20
+ </form>
19
21
  <div id="auth-error" class="alert alert-error text-xs py-2 hidden"></div>
20
22
  </div>
21
23
  </div>
@@ -72,8 +74,8 @@ export const AppShell = (opts = {}) => {
72
74
  <div class="flex gap-2 topbar-actions">
73
75
  <button id="btn-refresh-conn" class="btn btn-ghost btn-xs border border-base-300" title="重新连接并同步">↻</button>
74
76
  <button id="btn-pwa-install" class="btn btn-ghost btn-xs border border-base-300 hidden" title="安装到主屏幕">安装</button>
75
- <button id="btn-meta-agent" class="btn btn-ghost btn-xs border border-primary/40 text-primary" title="Meta Agent — 报告 / 问答 / VLM">✦ Agent</button>
76
- <button id="btn-new-session" class="btn btn-ghost btn-xs border border-base-300">+ New</button>
77
+ <button id="btn-meta-agent" class="btn btn-ghost btn-xs border border-primary/40 text-primary" title="Meta Agent — 报告 / 问答 / VLM"><span class="hidden sm:inline">✦ Agent</span><span class="sm:hidden">✦</span></button>
78
+ <button id="btn-new-session" class="btn btn-ghost btn-xs border border-base-300" title="New session"><span class="hidden sm:inline">+ New</span><span class="sm:hidden">+</span></button>
77
79
  <button id="btn-theme" class="btn btn-ghost btn-xs border border-base-300">🌙</button>
78
80
  <button id="btn-settings" class="btn btn-ghost btn-xs border border-base-300">⚙️</button>
79
81
  <button id="btn-logout" class="btn btn-ghost btn-xs border border-base-300">Sign out</button>
@@ -238,8 +240,10 @@ export const AppShell = (opts = {}) => {
238
240
  <button id="scroll-bottom-btn" class="scroll-fab hidden" title="Jump to bottom">↓</button>
239
241
  </div>
240
242
  <div id="permission-requests"></div>
243
+ <form id="chat-form" autocomplete="off" action="#" class="flex flex-col flex-shrink-0">
241
244
  <div id="chat-composer" class="flex flex-col border-t border-base-300 bg-base-200 flex-shrink-0">
242
- <div id="chat-opts" class="hidden flex items-center gap-1.5 px-3 pt-2 flex-wrap">
245
+ <div id="chat-attach-bar" class="hidden flex flex-wrap gap-1.5 px-3 pt-2"></div>
246
+ <div id="chat-opts" class="hidden flex items-center gap-1.5 px-3 pt-2 flex-wrap" inert>
243
247
  <select id="sel-agent" class="select select-xs select-bordered text-xs font-semibold" title="Agent">
244
248
  <option value="claude">Claude</option>
245
249
  <option value="codex">Codex</option>
@@ -282,23 +286,37 @@ export const AppShell = (opts = {}) => {
282
286
  <option value="full">density: full</option>
283
287
  </select>
284
288
  </div>
285
- <div id="composer-row" class="flex gap-2 items-end px-3 pt-2 pb-2">
286
- <button id="btn-opts" class="btn btn-ghost btn-sm px-2 text-base-content/30 hover:text-base-content flex-shrink-0" title="Options">⚙</button>
287
- <textarea id="chat-input" rows="1" placeholder="Ask… (!cmd shell · ⌘↵ send)"
288
- class="chat-input flex-1 text-sm resize-none leading-relaxed"
289
- name="agent-chat-message"
290
- autocomplete="off"
291
- autocorrect="off"
292
- autocapitalize="off"
293
- spellcheck="false"
294
- data-1p-ignore="true"
295
- data-lpignore="true"
296
- data-form-type="other"
297
- data-bwignore="true"></textarea>
298
- <button id="send-btn" class="btn btn-primary btn-sm flex-shrink-0">Send</button>
299
- <button id="stop-btn" class="btn btn-error btn-sm hidden flex-shrink-0">■</button>
289
+ <div id="composer-row" class="px-3 pt-2 pb-2">
290
+ <div class="composer-surface">
291
+ <textarea id="chat-input" rows="1" placeholder="问点什么…"
292
+ class="chat-input w-full text-sm resize-none leading-relaxed"
293
+ name="q"
294
+ enterkeyhint="send"
295
+ inputmode="text"
296
+ autocomplete="off"
297
+ autocorrect="off"
298
+ autocapitalize="sentences"
299
+ spellcheck="false"
300
+ data-1p-ignore="true"
301
+ data-lpignore="true"
302
+ data-form-type="other"
303
+ data-bwignore="true"></textarea>
304
+ <div class="composer-actions">
305
+ <div class="composer-tools">
306
+ <button type="button" id="btn-opts" class="composer-icon-btn" title="Options" aria-label="Options">⚙</button>
307
+ <button type="button" id="btn-attach" class="composer-icon-btn" title="添加图片 / 文件" aria-label="添加图片 / 文件">📎</button>
308
+ <input type="file" id="chat-file-input" class="hidden" multiple accept="image/*,*/*" tabindex="-1">
309
+ </div>
310
+ <div class="composer-submit">
311
+ <span class="composer-hint">Enter 发送</span>
312
+ <button type="submit" id="send-btn" class="composer-send-btn">Send</button>
313
+ <button type="button" id="stop-btn" class="composer-stop-btn hidden" aria-label="停止生成">■</button>
314
+ </div>
315
+ </div>
316
+ </div>
300
317
  </div>
301
318
  </div>
319
+ </form>
302
320
  </div>
303
321
  <div id="tab-files" class="hidden flex-col flex-1 overflow-hidden">
304
322
  <div class="flex items-center gap-2 px-3 py-1.5 border-b border-base-300 flex-shrink-0 bg-base-200">
package/public/shell.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from './state.js';
15
15
  import { api } from './api.js';
16
16
  import { promptPwaInstall } from './pwa.js';
17
+ import { syncFocusScope } from './mobile.js';
17
18
  import { getLastSessionContext } from './shell/session-context.js';
18
19
  import {
19
20
  connectWS, clearMessages, appendMsg, appendSystemMsg, sendMessage, stopProcessing,
@@ -66,7 +67,8 @@ function showPwaInstallHelp() {
66
67
  <li>下滑找到并点 <strong>添加到主屏幕</strong></li>
67
68
  <li>确认名称后点 <strong>添加</strong></li>
68
69
  </ol>
69
- <p class="text-xs text-base-content/50 mt-3">需用 <strong>Safari</strong> 打开本站;微信/Chrome 内置浏览器通常没有「添加到主屏幕」。</p>`;
70
+ <p class="text-xs text-base-content/50 mt-3">需用 <strong>Safari</strong> 打开本站;微信/Chrome 内置浏览器通常没有「添加到主屏幕」。</p>
71
+ <p class="text-xs text-warning/80 mt-2">当前若是 IP + 自签 HTTPS,Safari 仍会显示「不安全」。加到主屏幕可去掉浏览器外壳,但不能去掉系统输入条。彻底干净需要正式域名证书。</p>`;
70
72
  } else if (isAndroid) {
71
73
  body = `<ol class="list-decimal pl-4 space-y-2 text-sm text-base-content/80">
72
74
  <li>点右上角菜单 <strong>⋮</strong></li>
@@ -107,8 +109,30 @@ export async function initAuth() {
107
109
  app.classList.add('hidden');
108
110
  app.style.display = 'none';
109
111
  }
110
- const status = await api('GET', '/api/auth/status');
111
112
  const btn = $('auth-btn');
113
+ let status;
114
+ const controller = new AbortController();
115
+ const timer = setTimeout(() => controller.abort(), 8000);
116
+ try {
117
+ // This is the first request made by a cold PWA launch. Bound it so a
118
+ // captive portal, stale reverse proxy, or sleeping hub cannot strand the
119
+ // app behind its boot screen indefinitely.
120
+ status = await api('GET', '/api/auth/status', undefined, controller.signal);
121
+ } catch (e) {
122
+ console.warn('[auth status]', e);
123
+ const error = $('auth-error');
124
+ if (error) {
125
+ error.textContent = '无法连接服务器。请检查网络或服务地址后重试。';
126
+ error.classList.remove('hidden');
127
+ }
128
+ // The normal sign-in form remains usable: a transient status failure
129
+ // should be visible, not turn the installed app into a black screen.
130
+ if (btn) btn.dataset.mode = 'login';
131
+ return;
132
+ } finally {
133
+ clearTimeout(timer);
134
+ }
135
+
112
136
  if (status.needsSetup) {
113
137
  $('auth-subtitle').textContent = 'Create your account to get started';
114
138
  btn.textContent = 'Create Account';
@@ -276,6 +300,7 @@ export function initShell(opts = {}) {
276
300
  document.dispatchEvent(new CustomEvent('shell-tab-shown'));
277
301
  });
278
302
  }
303
+ syncFocusScope();
279
304
  });
280
305
 
281
306
  initSidebarResize();
@@ -307,6 +332,7 @@ export function initShell(opts = {}) {
307
332
 
308
333
  delegate.on('click', '#btn-home', goHome);
309
334
  delegate.on('click', '#auth-btn', submitAuth);
335
+ delegate.on('submit', '#auth-form', e => { e.preventDefault(); submitAuth(); });
310
336
  delegate.on('keydown', '#auth-username, #auth-password', e => { if (e.key === 'Enter') submitAuth(); });
311
337
  delegate.on('click', '#hamburger', () => $('sidebar').classList.contains('open') ? closeSidebar() : openSidebar());
312
338
  delegate.on('click', '#sidebar-overlay', closeSidebar);
@@ -460,7 +486,10 @@ export function initShell(opts = {}) {
460
486
  delegate.on('click', '#git-root-btn', () => applyRoot('git-root-input', gitRoot));
461
487
  delegate.on('keydown', '#files-root-input', e => { if (e.key === 'Enter') applyRoot('files-root-input', filesRoot); });
462
488
  delegate.on('keydown', '#git-root-input', e => { if (e.key === 'Enter') applyRoot('git-root-input', gitRoot); });
463
- delegate.on('click', '#btn-opts', () => { $('chat-opts').classList.toggle('hidden'); });
489
+ delegate.on('click', '#btn-opts', () => {
490
+ $('chat-opts').classList.toggle('hidden');
491
+ syncFocusScope();
492
+ });
464
493
 
465
494
  delegate.on('change', '#sel-agent', (_, el) => {
466
495
  const prev = currentAgent.peek();
@@ -470,6 +499,7 @@ export function initShell(opts = {}) {
470
499
  setAgent(next);
471
500
  currentModel.value = getDefaultModel(next) || currentModel.peek();
472
501
  localStorage.setItem('model', currentModel.peek());
502
+ $('sel-permission').value = currentPermission.peek();
473
503
  refreshModelSelect();
474
504
  appendSystemMsg(`Agent → ${AGENT_LABELS[next] || next} · model ${currentModel.peek()} · new session`);
475
505
  }
@@ -700,6 +730,7 @@ export function initShell(opts = {}) {
700
730
  });
701
731
 
702
732
  refreshModelSelect();
733
+ $('sel-permission').value = currentPermission.peek();
703
734
  applyChatDensity();
704
735
  const dens = $('sel-density');
705
736
  if (dens) dens.value = chatDensity.peek() || 'normal';
@@ -718,4 +749,3 @@ export function initShell(opts = {}) {
718
749
  btn.classList.toggle('text-base-content/50', !on);
719
750
  });
720
751
  }
721
-
package/public/state.js CHANGED
@@ -18,7 +18,11 @@ export const wsStatus = signal('idle');
18
18
  export const currentAgent = signal(localStorage.getItem('agent') || 'claude'); // 'claude'|'codex'|'grok'
19
19
  export const currentModel = signal(localStorage.getItem('model') || 'claude-sonnet-4-5');
20
20
  export const currentEffort = signal('');
21
- export const currentPermission = signal('default'); // 'default'|'acceptEdits'|'bypassPermissions'|'plan'|'auto'
21
+ // Codex is intentionally trusted by default; users can still choose another
22
+ // mode from the expanded composer options before starting a turn.
23
+ export const currentPermission = signal(
24
+ currentAgent.peek() === 'codex' ? 'bypassPermissions' : 'default'
25
+ ); // 'default'|'acceptEdits'|'bypassPermissions'|'plan'|'auto'
22
26
  export const filesRoot = signal(''); // custom root override for Files tab
23
27
  export const gitRoot = signal(''); // custom root override for Git tab
24
28
  export const agentsMeta = signal(null); // from /api/agents (dynamic model lists)
@@ -247,6 +251,9 @@ export function setAgent(agent) {
247
251
  const a = (agent || 'claude').toLowerCase();
248
252
  currentAgent.value = a;
249
253
  localStorage.setItem('agent', a);
254
+ currentPermission.value = a === 'codex' ? 'bypassPermissions' : 'default';
255
+ const permissionSelect = typeof document === 'undefined' ? null : document.getElementById('sel-permission');
256
+ if (permissionSelect) permissionSelect.value = currentPermission.peek();
250
257
  const models = getModelsForAgent(a);
251
258
  const def = getDefaultModel(a);
252
259
  if (!models.some(m => m.value === currentModel.peek())) {
package/public/style.css CHANGED
@@ -13,28 +13,21 @@
13
13
  #sidebar.open { transform: translateX(0); box-shadow: 4px 0 20px rgba(0,0,0,.4); }
14
14
 
15
15
  /* Compact chat composer on phones — less height, less dead space under bar */
16
- #composer-row {
17
- gap: 0.4rem;
18
- padding: 0.4rem 0.55rem 0.35rem !important;
19
- }
16
+ #composer-row { padding: 0 !important; }
17
+ .composer-surface { min-height: 0; border-radius: 1.35rem; }
20
18
  #chat-input.chat-input,
21
19
  #chat-input {
22
- min-height: 36px !important;
23
- max-height: 96px !important;
24
- padding: 0.4rem 0.65rem !important;
25
- border-radius: 0.65rem;
20
+ min-height: 32px !important;
21
+ max-height: 72px !important;
22
+ padding: 0 !important;
23
+ border-radius: 1.15rem 1.15rem 0 0;
26
24
  /* 16px prevents iOS Safari auto-zoom on focus */
27
25
  font-size: 16px !important;
28
- line-height: 1.35 !important;
29
- }
30
- #btn-opts,
31
- #send-btn,
32
- #stop-btn {
33
- height: 36px !important;
34
- min-height: 36px !important;
35
- padding-left: 0.65rem;
36
- padding-right: 0.65rem;
26
+ line-height: 1.4 !important;
37
27
  }
28
+ .composer-icon-btn { width: 40px; height: 40px; font-size: 19px; }
29
+ .composer-send-btn, .composer-stop-btn { min-height: 40px; height: 40px; }
30
+ .composer-hint { display: none; }
38
31
  #chat-opts {
39
32
  padding-top: 0.35rem !important;
40
33
  padding-left: 0.55rem !important;
@@ -48,6 +41,64 @@
48
41
  .file-row-action {
49
42
  opacity: 1 !important;
50
43
  }
44
+
45
+ /* Tighter chrome so the thread is the product */
46
+ #topbar-project { display: none; }
47
+ #project-view > .flex.bg-base-200 { /* tab strip */
48
+ min-height: 40px;
49
+ }
50
+ #project-view [data-tab] {
51
+ padding: 0.55rem 0.85rem !important;
52
+ font-size: 13px !important;
53
+ }
54
+ #project-notes-bar,
55
+ #session-notes-bar {
56
+ font-size: 11px;
57
+ padding: 0.35rem 0.7rem !important;
58
+ }
59
+ #messages {
60
+ padding: 0.65rem 0.45rem !important;
61
+ gap: 0.55rem !important;
62
+ }
63
+ .msg-row { gap: 3px; }
64
+ .msg-row.assistant > div:not(.msg-ts),
65
+ .msg-row.error > div:not(.msg-ts) {
66
+ max-width: 100% !important;
67
+ border-radius: 1rem !important;
68
+ border-top-left-radius: 0.3rem !important;
69
+ }
70
+ .msg-row.user > div:not(.msg-ts) {
71
+ max-width: 88% !important;
72
+ border-radius: 1rem !important;
73
+ border-top-right-radius: 0.3rem !important;
74
+ }
75
+ #sidebar-resize,
76
+ #sidebar-toggle {
77
+ display: none !important;
78
+ }
79
+ .md {
80
+ font-size: 0.9375rem;
81
+ line-height: 1.6;
82
+ }
83
+ .md h1, .md h2, .md h3 {
84
+ font-size: 1.02em !important;
85
+ padding: 0.05em 0 0.15em 0.45em !important;
86
+ border-left-width: 3px !important;
87
+ background: none !important;
88
+ border-bottom: none !important;
89
+ margin: 0.7em 0 0.35em !important;
90
+ }
91
+ .md :not(pre) > code {
92
+ white-space: nowrap;
93
+ display: inline;
94
+ font-size: 0.82em;
95
+ padding: 0.08em 0.32em;
96
+ }
97
+ .token-bar {
98
+ font-size: 10px;
99
+ line-height: 1.45;
100
+ padding: 0.15rem 0.45rem;
101
+ }
51
102
  }
52
103
 
53
104
  /* File row actions: hover-reveal on desktop only */
@@ -72,6 +123,20 @@ html, body {
72
123
  max-height: 100dvh;
73
124
  overflow: hidden;
74
125
  }
126
+ /* iOS keyboard: shrink the app to the visual viewport so the composer
127
+ * sits above the keyboard instead of floating over Safari chrome. */
128
+ html.vv-keyboard,
129
+ html.vv-keyboard body {
130
+ height: var(--vvh, 100dvh);
131
+ max-height: var(--vvh, 100dvh);
132
+ }
133
+ html.vv-keyboard body {
134
+ position: fixed;
135
+ top: var(--vv-top, 0px);
136
+ left: 0;
137
+ right: 0;
138
+ width: 100%;
139
+ }
75
140
  html.pwa-standalone,
76
141
  html.pwa-standalone body {
77
142
  background: oklch(var(--b1));
@@ -89,11 +154,19 @@ html.pwa-standalone #topbar {
89
154
  * Inner #composer-row already has small padding; only add safe-area beyond that
90
155
  * so we don't stack large empty strips under the input.
91
156
  */
157
+ #chat-form {
158
+ flex-shrink: 0;
159
+ display: flex;
160
+ flex-direction: column;
161
+ }
92
162
  #chat-composer {
93
163
  padding-bottom: env(safe-area-inset-bottom, 0px);
94
164
  padding-left: env(safe-area-inset-left, 0px);
95
165
  padding-right: env(safe-area-inset-right, 0px);
96
166
  }
167
+ html.vv-keyboard #chat-composer {
168
+ padding-bottom: 0;
169
+ }
97
170
  /* Welcome / machine picker full-screen panels */
98
171
  html.pwa-standalone #welcome,
99
172
  html.pwa-standalone #machine-picker {
@@ -882,28 +955,123 @@ html.pwa-standalone .ma-panel {
882
955
  .diff-head { color: oklch(var(--bc)/.5); }
883
956
 
884
957
  /* ── Chat composer ───────────────────────────────────────────────────────── */
885
- /* Single outer border only — no DaisyUI nested focus ring / inner outline. */
886
- #composer-row #btn-opts,
887
- #composer-row #send-btn,
888
- #composer-row #stop-btn {
889
- height: 40px;
890
- min-height: 40px;
958
+ /* One calm surface holds the prompt and its actions, like a modern messenger. */
959
+ .composer-surface {
960
+ min-height: 0;
961
+ display: flex;
962
+ flex-direction: column;
963
+ overflow: hidden;
964
+ border: 1px solid color-mix(in oklch, var(--color-base-content) 16%, transparent);
965
+ border-radius: 1.35rem;
966
+ background: color-mix(in oklch, var(--color-base-100) 78%, var(--color-base-200));
967
+ box-shadow: 0 8px 24px color-mix(in oklch, black 12%, transparent), inset 0 1px 0 color-mix(in oklch, white 6%, transparent);
968
+ transition: border-color .15s ease, box-shadow .15s ease, background .15s ease;
969
+ }
970
+ .composer-surface:focus-within {
971
+ border-color: color-mix(in oklch, var(--color-primary) 62%, transparent);
972
+ box-shadow: 0 8px 28px color-mix(in oklch, black 15%, transparent), 0 0 0 3px color-mix(in oklch, var(--color-primary) 12%, transparent);
973
+ }
974
+ .composer-actions {
975
+ display: flex;
976
+ align-items: center;
977
+ justify-content: space-between;
978
+ gap: .75rem;
979
+ padding: 0;
980
+ }
981
+ .composer-tools, .composer-submit { display: flex; align-items: center; gap: .35rem; }
982
+ .composer-icon-btn,
983
+ .composer-send-btn,
984
+ .composer-stop-btn {
985
+ border: 0;
986
+ border-radius: 999px;
987
+ cursor: pointer;
988
+ transition: transform .15s ease, background .15s ease, color .15s ease;
989
+ }
990
+ .composer-icon-btn {
991
+ width: 36px;
992
+ height: 36px;
993
+ padding: 0;
994
+ color: color-mix(in oklch, var(--color-base-content) 72%, transparent);
995
+ background: transparent;
996
+ font-size: 17px;
997
+ line-height: 1;
998
+ }
999
+ .composer-icon-btn:hover { background: color-mix(in oklch, var(--color-base-content) 9%, transparent); color: var(--color-base-content); }
1000
+ .composer-send-btn, .composer-stop-btn { height: 36px; min-height: 36px; padding: 0 .95rem; font-size: .8125rem; font-weight: 700; }
1001
+ .composer-send-btn { color: oklch(var(--pc)); background: oklch(var(--p)); box-shadow: 0 3px 10px color-mix(in oklch, oklch(var(--p)) 38%, transparent); }
1002
+ .composer-send-btn:hover { transform: translateY(-1px); background: color-mix(in oklch, oklch(var(--p)) 88%, white); }
1003
+ .composer-send-btn.btn-warning { color: oklch(var(--wc)); background: oklch(var(--wa)); box-shadow: 0 3px 10px color-mix(in oklch, oklch(var(--wa)) 38%, transparent); }
1004
+ .composer-stop-btn { color: oklch(var(--ec)); background: oklch(var(--er)); }
1005
+ .composer-hint { color: color-mix(in oklch, var(--color-base-content) 43%, transparent); font-size: 11px; }
1006
+ .composer-surface:has(.textarea-warning) { border-color: color-mix(in oklch, var(--color-warning) 65%, transparent); }
1007
+ #chat-composer.chat-drop-active {
1008
+ outline: 2px dashed color-mix(in oklch, var(--color-primary) 70%, transparent);
1009
+ outline-offset: -2px;
1010
+ background: color-mix(in oklch, var(--color-primary) 8%, var(--color-base-200));
1011
+ }
1012
+ #chat-attach-bar:not(.hidden) {
1013
+ display: flex;
1014
+ }
1015
+ .chat-thumb {
1016
+ width: 64px;
1017
+ height: 64px;
1018
+ border-radius: 12px;
1019
+ overflow: hidden;
1020
+ border: 1px solid color-mix(in oklch, var(--color-base-content) 12%, transparent);
1021
+ position: relative;
1022
+ flex-shrink: 0;
1023
+ background: color-mix(in oklch, var(--color-base-content) 4%, var(--color-base-100));
1024
+ box-shadow: 0 1px 2px color-mix(in oklch, black 12%, transparent);
1025
+ }
1026
+ .chat-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
1027
+ .chat-thumb-path {
1028
+ display: flex;
1029
+ align-items: center;
1030
+ justify-content: center;
1031
+ width: 100%;
1032
+ height: 100%;
1033
+ font-size: 10px;
1034
+ padding: 4px;
1035
+ text-align: center;
1036
+ word-break: break-all;
1037
+ line-height: 1.15;
1038
+ color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
1039
+ background: color-mix(in oklch, var(--color-base-content) 6%, var(--color-base-100));
1040
+ }
1041
+ .chat-thumb-label {
1042
+ display: none;
1043
+ }
1044
+ .chat-thumb-x {
1045
+ position: absolute;
1046
+ top: 4px;
1047
+ right: 4px;
1048
+ background: rgba(255,255,255,.92);
1049
+ color: #334155;
1050
+ border: 1px solid rgba(15,23,42,.1);
1051
+ border-radius: 999px;
1052
+ font-size: 11px;
1053
+ width: 20px;
1054
+ height: 20px;
1055
+ cursor: pointer;
1056
+ line-height: 1;
1057
+ z-index: 1;
1058
+ box-shadow: 0 1px 3px rgba(15,23,42,.18);
891
1059
  }
892
1060
  #chat-input.chat-input,
893
1061
  #chat-input {
894
1062
  width: 100%;
895
- min-height: 40px;
896
- max-height: 140px;
897
- padding: 0.55rem 0.85rem;
898
- border-radius: 0.75rem;
899
- border: 1px solid color-mix(in oklch, var(--color-base-content) 16%, transparent);
900
- background: var(--color-base-100);
1063
+ min-height: 32px;
1064
+ max-height: 72px;
1065
+ padding: 0;
1066
+ border: 0;
1067
+ border-radius: 1.25rem 1.25rem 0 0;
1068
+ background: transparent;
901
1069
  color: var(--color-base-content);
902
1070
  outline: none !important;
903
1071
  box-shadow: none !important;
904
1072
  --tw-ring-shadow: 0 0 #0000 !important;
905
1073
  --tw-ring-offset-shadow: 0 0 #0000 !important;
906
- transition: border-color .15s ease;
1074
+ transition: background .15s ease;
907
1075
  /* Avoid iOS inherited large text scaling making the box look oversized */
908
1076
  -webkit-text-size-adjust: 100%;
909
1077
  }
@@ -974,7 +1142,7 @@ html.pwa-standalone .ma-panel {
974
1142
  #chat-input:focus-visible {
975
1143
  outline: none !important;
976
1144
  box-shadow: none !important;
977
- border-color: color-mix(in oklch, var(--color-primary) 55%, transparent);
1145
+ border-color: transparent;
978
1146
  }
979
1147
  #chat-input.textarea-warning {
980
1148
  border-color: color-mix(in oklch, var(--color-warning) 65%, transparent);
@@ -1075,6 +1243,26 @@ html.pwa-standalone .ma-panel {
1075
1243
  }
1076
1244
  .msg-row.user .msg-ts { text-align: right; }
1077
1245
 
1246
+ .token-bar {
1247
+ display: flex;
1248
+ align-items: flex-start;
1249
+ gap: 0.4rem;
1250
+ padding: 0.1rem 0.45rem;
1251
+ font-size: 10px;
1252
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1253
+ color: color-mix(in oklch, var(--color-base-content) 38%, transparent);
1254
+ line-height: 1.45;
1255
+ }
1256
+ .token-bar-mark {
1257
+ flex-shrink: 0;
1258
+ opacity: 0.55;
1259
+ }
1260
+ .token-bar-text {
1261
+ min-width: 0;
1262
+ overflow-wrap: anywhere;
1263
+ word-break: break-word;
1264
+ }
1265
+
1078
1266
  /* ── Long message collapse ───────────────────────────────────────────────── */
1079
1267
  .msg-card .msg-body { max-height: 8em; overflow: hidden; mask-image: linear-gradient(to bottom, black 60%, transparent 100%); }
1080
1268
  .msg-card.open .msg-body { max-height: none; mask-image: none; }
@@ -1303,3 +1491,12 @@ html.pwa-standalone .ma-panel {
1303
1491
  }
1304
1492
  .ma-report-md { font-size: 13px; }
1305
1493
  .ma-report-item { display: block; }
1494
+
1495
+ @media (max-width: 640px) {
1496
+ .md h1, .md h2, .md h3 {
1497
+ background: none !important;
1498
+ border-bottom: none !important;
1499
+ font-size: 1.02em !important;
1500
+ padding: 0.05em 0 0.15em 0.5em !important;
1501
+ }
1502
+ }
package/public/sw.js CHANGED
@@ -1,12 +1,12 @@
1
1
  /* Agent UI service worker — cache shell assets only.
2
2
  * Never intercepts WebSocket or mutates API semantics beyond offline fallback.
3
3
  * Bump CACHE when shipping UI that must invalidate old shells. */
4
- const CACHE = 'agent-ui-shell-v7';
4
+ const CACHE = 'agent-ui-shell-v9';
5
5
  const PRECACHE = [
6
6
  '/',
7
7
  '/index.html',
8
8
  '/manifest.webmanifest',
9
- '/style.css?v=ui-9',
9
+ '/style.css?v=ui-10',
10
10
  '/style.css',
11
11
  '/app.js',
12
12
  '/icons/icon-192.png',
package/src/app.js CHANGED
@@ -448,13 +448,27 @@ export function createApp() {
448
448
  const ext = (req.headers['x-filename'] || 'image.png').split('.').pop().replace(/[^a-z0-9]/gi, '') || 'png';
449
449
  const name = `claude-upload-${Date.now()}.${ext}`;
450
450
  const dest = path.join(os.tmpdir(), name);
451
- const writer = fsSync.createWriteStream(dest);
452
- req.pipe(writer);
453
- await new Promise((resolve, reject) => {
454
- writer.on('finish', resolve);
455
- writer.on('error', reject);
456
- req.on('error', reject);
457
- });
451
+ const chunks = [];
452
+ for await (const chunk of req) chunks.push(chunk);
453
+ let body = Buffer.concat(chunks);
454
+ // Tolerate mobile share sheets that label Base64 text as a PNG. Only
455
+ // decode when the result has a real image signature.
456
+ const text = body.toString('utf8').trim();
457
+ const m = /^data:image\/[a-z0-9.+-]+;base64,([a-z0-9+/=\s]+)$/i.exec(text);
458
+ const encoded = (m?.[1] || text).replace(/\s/g, '');
459
+ if (/^[a-z0-9+/=]+$/i.test(encoded) && encoded.length % 4 !== 1) {
460
+ try {
461
+ const decoded = Buffer.from(encoded, 'base64');
462
+ const image = decoded.length >= 3 && (
463
+ (decoded[0] === 0x89 && decoded[1] === 0x50 && decoded[2] === 0x4e)
464
+ || (decoded[0] === 0xff && decoded[1] === 0xd8 && decoded[2] === 0xff)
465
+ || decoded.subarray(0, 6).toString() === 'GIF87a'
466
+ || decoded.subarray(8, 12).toString() === 'WEBP'
467
+ );
468
+ if (image) body = decoded;
469
+ } catch { /* preserve the original upload */ }
470
+ }
471
+ await fs.writeFile(dest, body);
458
472
  res.json({ path: dest });
459
473
  })().catch(e => res.status(500).json({ error: e.message }));
460
474
  });