@spzxg/cli-ui 0.6.7 → 0.6.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.
@@ -18,6 +18,7 @@ import { BranchHistory, useBranchHistory } from "./BranchHistory";
18
18
  import { BranchNavigator } from "./BranchNavigator";
19
19
  import type { Quote } from "./composerDraft";
20
20
  import type { RunControlProps } from "./RunControls";
21
+ import type { ResolvedChatConfig } from "../../core/config";
21
22
  const QUIET_AFTER_MS = 180_000;
22
23
  function RunningIndicator({ agent, startedAt, lastActivityRef }: { agent: string | null; startedAt: number | null; lastActivityRef: React.MutableRefObject<number> }) {
23
24
  const [now, setNow] = useState(() => Date.now());
@@ -68,6 +69,7 @@ export type ChatProps = {
68
69
  running: boolean; blockedBy?: string[]; transcriptLoading?: boolean;
69
70
  older?: number; onLoadOlder?: (beforeApply?: () => void) => Promise<boolean>;
70
71
  controls: RunControlProps; configBusy: boolean; active?: boolean;
72
+ composer?: Pick<ResolvedChatConfig["composer"], "show" | "fileUpload">;
71
73
  onSelectTask: (id: string) => void; onFork: (text: string, sourceIds: string[], requestId: string) => Promise<boolean>;
72
74
  onSend: (text: string) => Promise<boolean>; onStop: () => Promise<boolean>;
73
75
  onStart: () => void; onEdit: () => void; onClear: () => void; onSetSendContext: (v: boolean) => void;
@@ -76,7 +78,7 @@ export type ChatProps = {
76
78
  };
77
79
  export function Chat(props: ChatProps) { return <ChatSession key={props.task.id} {...props} />; }
78
80
  function ChatSession({ project, task, tasks, agents, messages, running, transcriptLoading, older=0, onLoadOlder,
79
- controls, configBusy, active=true, onSelectTask, onFork, onSend, onStop, onClear, onReconnect, onAnswer, onCancelQueued }: ChatProps) {
81
+ controls, configBusy, composer, active=true, onSelectTask, onFork, onSend, onStop, onClear, onReconnect, onAnswer, onCancelQueued }: ChatProps) {
80
82
  const scrollRef = useRef<HTMLDivElement>(null);
81
83
  useChatScrollbar(scrollRef, task.id);
82
84
  const [quoteRequest, setQuoteRequest] = useState<Quote|null>(null);
@@ -439,6 +441,7 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
439
441
  pinned.current=false; setAtBottom(false); flowPositioned.current=true;
440
442
  },[active,flow.ready,task.parent_task_id]);
441
443
  useEffect(()=>{if(!task.parent_task_id && active) scrollToBottom("auto");},[active,task.id]);
444
+ const queued = <QueuedMessages scopeKey={task.id} messages={messages.filter(message => message.role === "queued")} onCancel={message => stableCancelQueued(message.id)} attachmentUrl={attachment => `/api/tasks/${encodeURIComponent(attachment.taskId)}/uploads/${encodeURIComponent(attachment.name)}`} renderMessage={message => <MessageView m={message} initial={false} hideWho readOnly />} />;
442
445
  return (<FileLinkContext.Provider value={fileScope}>
443
446
  <>
444
447
  <div className="transcript-wrap">
@@ -508,8 +511,8 @@ function ChatSession({ project, task, tasks, agents, messages, running, transcri
508
511
  )}
509
512
  </div>
510
513
  </div>
511
- <Composer tasks={tasks} onFork={onFork} task={task} agentLabel={agentLabel(agents, task.agent)} disabled={task.started!==1} configBusy={configBusy} running={running} onSend={async text => { const sent = await onSend(text); if (sent && !running) scrollToBottom("auto"); return sent; }} onStop={onStop} onClear={onClear} quoteRequest={quoteRequest} onQuoteHandled={()=>setQuoteRequest(null)} controls={controls}
512
- queued={<QueuedMessages scopeKey={task.id} messages={messages.filter(message => message.role === "queued")} onCancel={message => stableCancelQueued(message.id)} attachmentUrl={attachment => `/api/tasks/${encodeURIComponent(attachment.taskId)}/uploads/${encodeURIComponent(attachment.name)}`} renderMessage={message => <MessageView m={message} initial={false} hideWho readOnly />} />} />
514
+ {composer?.show !== false ? <Composer tasks={tasks} onFork={onFork} task={task} agentLabel={agentLabel(agents, task.agent)} disabled={task.started!==1} configBusy={configBusy} running={running} onSend={async text => { const sent = await onSend(text); if (sent && !running) scrollToBottom("auto"); return sent; }} onStop={onStop} onClear={onClear} quoteRequest={quoteRequest} onQuoteHandled={()=>setQuoteRequest(null)} controls={controls}
515
+ fileUpload={composer?.fileUpload} queued={queued} /> : queued}
513
516
  </></FileLinkContext.Provider>
514
517
  );
515
518
 
@@ -21,12 +21,13 @@ import { acknowledgeDraft, DRAFT_EVENT, isTaskSending, setTaskSending, readDraft
21
21
  import { getOperatorBoardBackend } from "./boardBackend";
22
22
  import { OperatorBoardContext, openOperatorBoard } from "./boardInteraction";
23
23
 
24
- export function Composer({ task, agentLabel, disabled, running, onSend, onStop, onClear, quoteRequest, onQuoteHandled, controls, configBusy, tasks, onFork, queued }: {
24
+ export function Composer({ task, agentLabel, disabled, running, onSend, onStop, onClear, quoteRequest, onQuoteHandled, controls, configBusy, tasks, onFork, queued, fileUpload = true }: {
25
25
  task: TaskRow; agentLabel: string; disabled: boolean; running: boolean;
26
26
  tasks: TaskRow[]; onFork: (text: string, sourceIds: string[], requestId: string) => Promise<boolean>;
27
27
  onSend: (text: string) => Promise<boolean>; onStop: () => Promise<boolean>; onClear: () => void;
28
28
  configBusy: boolean; quoteRequest: Quote | null; onQuoteHandled: () => void; controls: RunControlProps;
29
29
  queued?: ReactNode;
30
+ fileUpload?: boolean;
30
31
  }) {
31
32
  const boardView=useBoardState(task.id);
32
33
  const [draft, setDraft] = useState(() => readDraft(task.id));
@@ -116,7 +117,7 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
116
117
  const sharedConfigBusy = useConfigurationBusy(task.id);
117
118
  const locked = disabled || sending || configBusy || sharedConfigBusy;
118
119
  const startUpload = (key: string, file: File) => {
119
- if (!backend.uploadFile || locked || uploads.current.has(key)) return;
120
+ if (!fileUpload || !backend.uploadFile || locked || uploads.current.has(key)) return;
120
121
  const taskId = task.id;
121
122
  const preview = draftRef.current.attachments.find(a => a.key === key)?.preview;
122
123
  const controller = new AbortController();
@@ -143,7 +144,7 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
143
144
  });
144
145
  };
145
146
  const addFiles = (files: File[]) => {
146
- if (locked) return;
147
+ if (locked || !fileUpload) return;
147
148
  setError("");
148
149
  if (!backend.uploadFile) { setError("当前服务暂不支持附件上传。"); return; }
149
150
  for (const f of files) {
@@ -249,7 +250,7 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
249
250
  {slash && !running && !locked && "/clear".startsWith(draft.text.trim()) && <div className="slash"><button className="slash-item" onClick={clear}><span className="cmd">/clear</span><span className="cd">保存摘要 · 开启新上下文</span></button></div>}
250
251
  {queued}
251
252
  <div className={`comp-box${dragging ? " dropping" : ""}`}
252
- onDragEnter={(e) => { if (hasFileDrag(e)) { e.preventDefault(); if (!locked) { dragDepth.current++; setDragging(true); } } }}
253
+ onDragEnter={(e) => { if (hasFileDrag(e)) { e.preventDefault(); if (fileUpload && !locked) { dragDepth.current++; setDragging(true); } } }}
253
254
  onDragOver={(e) => { if (hasFileDrag(e)) e.preventDefault(); }}
254
255
  onDragLeave={() => { if (dragDepth.current > 0 && --dragDepth.current === 0) setDragging(false); }}
255
256
  onDrop={(e) => { if (!hasFileDrag(e)) return; e.preventDefault(); dragDepth.current = 0; setDragging(false); addFiles(Array.from(e.dataTransfer.files)); }}>
@@ -258,7 +259,7 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
258
259
  {draft.attachments.some(a => a.status !== "ready") && <div className="composer-upload-list">{draft.attachments.filter(a => a.status !== "ready").map(a => <UploadProgress key={a.key} name={a.name}
259
260
  status={a.status === "error" ? "error" : a.phase ?? "queued"} bytes={a.bytes} total={a.size} disabled={locked}
260
261
  error={`${a.error || "上传失败,请重试。"}${uploadFiles.current.has(a.key) || a.error?.includes("重新选择文件") ? "" : " 请重新选择文件。"}`}
261
- onCancel={() => removeAtt(a.key)} onRemove={() => removeAtt(a.key)} onRetry={uploadFiles.current.has(a.key) ? () => startUpload(a.key, uploadFiles.current.get(a.key)!) : undefined} />)}</div>}
262
+ onCancel={() => removeAtt(a.key)} onRemove={() => removeAtt(a.key)} onRetry={fileUpload && uploadFiles.current.has(a.key) ? () => startUpload(a.key, uploadFiles.current.get(a.key)!) : undefined} />)}</div>}
262
263
  {readyAttachments.length > 0 && <div className="attach-row">{readyAttachments.map((a) => <div key={a.key} className={`attach-chip ${a.kind} ${a.status}`} title={a.name}>
263
264
  {a.kind === "image" && a.preview ? <img src={a.preview} alt={a.name} /> : <span className="attach-file">{Icon.clip()} {a.name}</span>}
264
265
  <button className="attach-x" disabled={locked} aria-label={`移除 ${a.name}`} onClick={() => removeAtt(a.key)}>×</button>
@@ -282,16 +283,16 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
282
283
  const files = Array.from(e.clipboardData.files);
283
284
  if (files.length) { e.preventDefault(); addFiles(files); return; }
284
285
  const text = e.clipboardData.getData("text/plain");
285
- if (text.length > PASTE_ATTACH_THRESHOLD) { e.preventDefault(); addFiles([new File([text], "pasted-text.txt", { type: "text/plain" })]); }
286
+ if (fileUpload && text.length > PASTE_ATTACH_THRESHOLD) { e.preventDefault(); addFiles([new File([text], "pasted-text.txt", { type: "text/plain" })]); }
286
287
  }} /></div>
287
288
  <div className="comp-toolbar">
288
- <input ref={fileRef} type="file" accept={FILE_UPLOAD_ACCEPT} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />
289
- <div className="comp-control"><button className={`comp-add${addOpen ? " on" : ""}`} disabled={locked} title="附件、派生任务与新上下文" aria-label="添加内容" aria-haspopup="dialog" aria-expanded={addOpen} onClick={(e) => { e.stopPropagation(); setAddOpen(!addOpen); }}>{Icon.plus()}</button>
289
+ {fileUpload && <input ref={fileRef} type="file" accept={FILE_UPLOAD_ACCEPT} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />}
290
+ <div className="comp-control"><button className={`comp-add${addOpen ? " on" : ""}`} disabled={locked} title={fileUpload ? "附件、派生任务与新上下文" : "派生任务与新上下文"} aria-label="添加内容" aria-haspopup="dialog" aria-expanded={addOpen} onClick={(e) => { e.stopPropagation(); setAddOpen(!addOpen); }}>{Icon.plus()}</button>
290
291
  {addOpen && <Popover className="composer-popover composer-add-popover" ariaLabel="添加内容" placement="top" align="start" onClose={() => setAddOpen(false)}>
291
292
  <div className="composer-menu-heading">添加内容</div>
292
- <button className="pop-item composer-option" disabled={locked} onClick={() => { setAddOpen(false); fileRef.current?.click(); }}>
293
+ {fileUpload && <button className="pop-item composer-option" disabled={locked} onClick={() => { setAddOpen(false); fileRef.current?.click(); }}>
293
294
  <span className="composer-option-icon">{Icon.clip()}</span><span className="composer-option-copy"><span className="composer-option-title">添加附件</span><span className="composer-option-sub">HTML、Markdown 最多 100 MiB,其他文件最多 10 MiB</span></span>
294
- </button>
295
+ </button>}
295
296
  <button className="pop-item composer-option" disabled={locked} onClick={openMention}>
296
297
  <span className="composer-option-icon">{Icon.git()}</span><span className="composer-option-copy"><span className="composer-option-title">从已完成任务派生</span><span className="composer-option-sub">带上已完成的任务,在新分支继续工作</span></span><span className="composer-option-trailing composer-menu-key">@</span>
297
298
  </button>
@@ -302,8 +303,8 @@ export function Composer({ task, agentLabel, disabled, running, onSend, onStop,
302
303
  </div>
303
304
  <RunControls {...controls} disabled={controls.disabled || sending} />
304
305
  <div className="send-group">
305
- {(!running || !!payload) && <button className={`send${running ? " queue" : ""}`} disabled={!canSend} aria-busy={sending} aria-label={sources.length ? `派生 ${sources.length} 个分支` : running ? "排队发送" : "发送消息"} title={sending ? "发送中…" : running ? "本轮结束后发送" : "发送消息(Enter)"} onClick={() => void submit()}>{sending ? <span className="spinner" /> : Icon.send()}</button>}
306
- {running && <button className="send stop" disabled={stopping} aria-busy={stopping} aria-label="停止当前轮次" title={stopping ? "正在停止…" : "停止当前轮次"} onClick={() => void stop()}>{stopping ? <span className="spinner" /> : Icon.stop()}</button>}
306
+ {(!running || !!payload || sending) && <button type="button" className={`send${running ? " queue" : ""}`} disabled={!canSend} aria-busy={sending} aria-label={sending ? "正在发送" : sources.length ? `派生 ${sources.length} 个分支` : running ? "排队发送" : "发送消息"} title={sending ? "发送中…" : running ? "本轮结束后发送" : "发送消息(Enter)"} onClick={() => void submit()}>{sending ? <span className="spinner" /> : Icon.send()}</button>}
307
+ {running && <button type="button" className="send stop" disabled={stopping} aria-busy={stopping} aria-label={stopping ? "正在停止" : "停止当前轮次"} title={stopping ? "正在停止…" : "停止当前轮次"} onClick={() => void stop()}>{stopping ? <span className="spinner" /> : Icon.stop()}</button>}
307
308
  </div>
308
309
  </div>
309
310
  </div>
@@ -30,6 +30,7 @@ import { ColResize } from "./Layout";
30
30
  import { WorkspaceTabs } from "../../ui/WorkspaceTabs";
31
31
  import { WorkspaceDrawer, useWorkspaceDrawer } from "../../ui/WorkspaceDrawer";
32
32
  import { jget, downloadExport } from "./api";
33
+ import { resolveChatConfig, type CliChatConfig } from "../../core/config";
33
34
 
34
35
  // Non-blocking banner shown when a reopened task's worktree is behind its base
35
36
  // branch. Computed (read-only) on open; the actual git op fires only when the user
@@ -92,8 +93,9 @@ function SyncBanner({ taskId, running, pinned, onResolveWithAI, onSwitchToChat }
92
93
  }
93
94
 
94
95
  export function SessionView(props: Parameters<typeof SessionViewInner>[0]) { return <SessionViewInner key={props.task.id} {...props}/>; }
95
- function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents, messages, running, blockedBy, transcriptLoading, older=0, onLoadOlder, onSend, onStart, onStop, onClear, clearConfirming, onConfirmClear, onCancelClear, onEdit, onReconnect, onSetStatus, onSetPriority, onSetModel, onSetAgent, onSetReasoning, onSetPermission, onSetSendContext, onResolveWithAI, onMerged, onPrCreated, onAnswer, onCancelQueued, onBack, mobile, railW, onRailWidth, onRailReset, railCollapsed, onRailCollapse, onRailExpand, board }: {
96
+ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents, messages, running, blockedBy, transcriptLoading, older=0, onLoadOlder, onSend, onStart, onStop, onClear, clearConfirming, onConfirmClear, onCancelClear, onEdit, onReconnect, onSetStatus, onSetPriority, onSetModel, onSetAgent, onSetReasoning, onSetPermission, onSetSendContext, onResolveWithAI, onMerged, onPrCreated, onAnswer, onCancelQueued, onBack, mobile, railW, onRailWidth, onRailReset, railCollapsed, onRailCollapse, onRailExpand, board, composer }: {
96
97
  board?: OperatorBoardOptions;
98
+ composer?: CliChatConfig["composer"];
97
99
  tasks: TaskRow[]; onSelectTask: (id: string) => void; onFork: (text: string, sourceIds: string[], requestId: string) => Promise<boolean>;
98
100
  project: ProjectRow; task: TaskRow; agents: AgentsBundle; messages: Msg[]; running: boolean; blockedBy?: string[]; transcriptLoading?: boolean;
99
101
  older?: number; onLoadOlder?: (beforeApply?: () => void) => Promise<boolean>;
@@ -181,7 +183,8 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
181
183
  // Run-control pickers + feature gates come from this task's agent capabilities,
182
184
  // never a hardcoded list — so the options always match the agent it runs under.
183
185
  const caps = capsFor(agents, task.agent);
184
- const controls = { task, caps, agents:agents.agents.map(a=>({id:a.id,label:a.label,disabled:!a.authenticated||a.installed===false})), onSetAgent, defaults: findAgent(agents, task.agent)?.defaults, disabled: running, onSetModel, onSetReasoning, onSetPermission };
186
+ const composerConfig = resolveChatConfig({ composer }).composer;
187
+ const controls = { task, caps, agents:agents.agents.map(a=>({id:a.id,label:a.label,disabled:!a.authenticated||a.installed===false})), onSetAgent, defaults: findAgent(agents, task.agent)?.defaults, disabled: running, onSetModel, onSetReasoning, onSetPermission, showAgent: composerConfig.agentSelector, showPermission: composerConfig.executionMode };
185
188
  // Usage chip: tokens split into fresh work vs re-read cache (the raw total is
186
189
  // mostly cache reads and wildly overstates what ran), and a dollar figure whose
187
190
  // presentation follows how this agent is signed in — a subscription login's
@@ -259,7 +262,7 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
259
262
  )}
260
263
 
261
264
  {!hasSession ? (
262
- <><div className="hero-controls"><RunControls {...controls} /></div><TaskHero task={task} project={project} onStart={onStart} onEdit={onEdit} onSetSendContext={onSetSendContext} running={running || configBusy} blockedBy={blockedBy} /></>
265
+ <>{composerConfig.show && <div className="hero-controls"><RunControls {...controls} /></div>}<TaskHero task={task} project={project} onStart={onStart} onEdit={onEdit} onSetSendContext={onSetSendContext} running={running || configBusy} blockedBy={blockedBy} /></>
263
266
  ) : (
264
267
  // Keep the chat at the same React position across all widths. Moving
265
268
  // it between different parents remounted the composer during resize
@@ -267,7 +270,7 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
267
270
  <div className={tabMode||drawerMode ? "sess-responsive" : "sess-split"} style={tabMode||drawerMode ? undefined : { gridTemplateColumns: `minmax(0,1fr) 0px ${railW}px` }}>
268
271
  <div className={`sess-main${tabMode||drawerMode ? " compact-chat" : ""}`} data-chat-layout={tabMode||drawerMode?"centered":"wide"} hidden={tabMode && view !== "chat"}><Chat project={project} task={task} tasks={tasks} agents={agents} messages={messages}
269
272
  running={running} blockedBy={blockedBy} transcriptLoading={transcriptLoading} older={older} onLoadOlder={onLoadOlder}
270
- controls={controls} configBusy={configBusy} active={!tabMode&&!drawerMode || view === "chat"}
273
+ controls={controls} configBusy={configBusy} composer={composerConfig} active={!tabMode&&!drawerMode || view === "chat"}
271
274
  onSelectTask={onSelectTask} onFork={onFork} onSend={onSend} onStop={onStop}
272
275
  onStart={onStart} onEdit={onEdit} onClear={onClear} onSetSendContext={onSetSendContext}
273
276
  onReconnect={onReconnect} onAnswer={onAnswer} onCancelQueued={onCancelQueued} /></div>
@@ -1029,11 +1029,15 @@ button.tpeek-more:hover{color:var(--accent-ink);}
1029
1029
  .comp-setting.elevated{color:var(--amber);}
1030
1030
  .comp-model-name{min-width:0;max-width:220px;overflow:hidden;text-overflow:ellipsis;}
1031
1031
  .comp-setting:disabled,.comp-add:disabled{opacity:.5;cursor:default;}
1032
- .send{width:36px;height:36px;flex:none;border-radius:50%;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;transition:filter .12s,opacity .12s;}
1033
- .send:hover{filter:brightness(1.07);}
1034
- .send:disabled{opacity:.35;cursor:default;filter:none;}
1035
- .send.stop{background:var(--blue);color:#fff;}
1032
+ .send{width:44px;height:44px;min-width:44px;min-height:44px;flex:none;padding:0;border-radius:50%;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;transition:filter .12s,opacity .12s,transform .12s;}
1033
+ .send:hover:not(:disabled){filter:brightness(1.08);}
1034
+ .send:active:not(:disabled){transform:scale(.96);filter:brightness(.94);}
1035
+ .send:disabled{opacity:.35;cursor:not-allowed;filter:none;}
1036
+ .send[aria-busy="true"]{opacity:.7;cursor:progress;}
1037
+ .send.stop{background:var(--blue);color:var(--on-accent);}
1036
1038
  .send.stop svg{fill:currentColor;width:15px;height:15px;}
1039
+ .send .spinner{width:18px;height:18px;border-color:color-mix(in srgb,currentColor 30%,transparent);border-top-color:currentColor;}
1040
+ @media(prefers-reduced-motion:reduce){.send{transition:none;}.send .spinner{animation:none;}}
1037
1041
  /* mid-turn: queue button + stop button sit side by side */
1038
1042
  .send-group{display:flex;align-items:flex-end;gap:8px;flex:0 0 auto;}
1039
1043
  .send svg{width:17px;height:17px;}
@@ -1412,7 +1416,7 @@ button.pop-item:hover{background:var(--surface-2);}
1412
1416
  .composer .comp-setting{min-height:38px;border-radius:6px;padding:7px 10px;background:transparent;}
1413
1417
  .composer .comp-add{width:38px;height:38px;border-radius:6px;background:transparent;}
1414
1418
  .composer .comp-setting:hover:not(:disabled),.composer .comp-add:hover:not(:disabled){background:var(--surface-2);}
1415
- .composer .send{width:38px;height:38px;}
1419
+ .composer .send{width:44px;height:44px;}
1416
1420
  .composer .comp-box{border-radius:26px;border-color:var(--line);box-shadow:0 4px 20px rgba(0,0,0,.035);}
1417
1421
  .composer .comp-box:focus-within{border-color:var(--line-strong);box-shadow:0 6px 24px rgba(0,0,0,.055);}
1418
1422
  .composer .comp-toolbar{gap:6px;padding:6px 12px 12px;}
@@ -1549,7 +1553,7 @@ button.pop-item:hover{background:var(--surface-2);}
1549
1553
  the home indicator. The keyboard hints are mouse-era noise on a phone. */
1550
1554
  .app.mobile .composer{padding:10px 12px calc(12px + env(safe-area-inset-bottom));}
1551
1555
  .app.mobile .comp-area textarea{font-size:16px;}
1552
- .app.mobile .send{width:40px;height:40px;}
1556
+ .app.mobile .send{width:44px;height:44px;}
1553
1557
 
1554
1558
  /* Ask card options are tap targets — give them room. */
1555
1559
  .app.mobile .ask-opt{padding:10px 13px;}
package/src/operator.css CHANGED
@@ -1029,11 +1029,15 @@
1029
1029
  .cli-ui .comp-setting.elevated{color:var(--amber);}
1030
1030
  .cli-ui .comp-model-name{min-width:0;max-width:220px;overflow:hidden;text-overflow:ellipsis;}
1031
1031
  .cli-ui .comp-setting:disabled,.cli-ui .comp-add:disabled{opacity:.5;cursor:default;}
1032
- .cli-ui .send{width:36px;height:36px;flex:none;border-radius:50%;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;transition:filter .12s,opacity .12s;}
1033
- .cli-ui .send:hover{filter:brightness(1.07);}
1034
- .cli-ui .send:disabled{opacity:.35;cursor:default;filter:none;}
1035
- .cli-ui .send.stop{background:var(--blue);color:#fff;}
1032
+ .cli-ui .send{width:44px;height:44px;min-width:44px;min-height:44px;flex:none;padding:0;border-radius:50%;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;transition:filter .12s,opacity .12s,transform .12s;}
1033
+ .cli-ui .send:hover:not(:disabled){filter:brightness(1.08);}
1034
+ .cli-ui .send:active:not(:disabled){transform:scale(.96);filter:brightness(.94);}
1035
+ .cli-ui .send:disabled{opacity:.35;cursor:not-allowed;filter:none;}
1036
+ .cli-ui .send[aria-busy="true"]{opacity:.7;cursor:progress;}
1037
+ .cli-ui .send.stop{background:var(--blue);color:var(--on-accent);}
1036
1038
  .cli-ui .send.stop svg{fill:currentColor;width:15px;height:15px;}
1039
+ .cli-ui .send .spinner{width:18px;height:18px;border-color:color-mix(in srgb,currentColor 30%,transparent);border-top-color:currentColor;}
1040
+ @media(prefers-reduced-motion:reduce){.cli-ui .send{transition:none;}.cli-ui .send .spinner{animation:none;}}
1037
1041
  /* mid-turn: queue button + stop button sit side by side */
1038
1042
  .cli-ui .send-group{display:flex;align-items:flex-end;gap:8px;flex:0 0 auto;}
1039
1043
  .cli-ui .send svg{width:17px;height:17px;}
@@ -1412,7 +1416,7 @@
1412
1416
  .cli-ui .composer .comp-setting{min-height:38px;border-radius:6px;padding:7px 10px;background:transparent;}
1413
1417
  .cli-ui .composer .comp-add{width:38px;height:38px;border-radius:6px;background:transparent;}
1414
1418
  .cli-ui .composer .comp-setting:hover:not(:disabled),.cli-ui .composer .comp-add:hover:not(:disabled){background:var(--surface-2);}
1415
- .cli-ui .composer .send{width:38px;height:38px;}
1419
+ .cli-ui .composer .send{width:44px;height:44px;}
1416
1420
  .cli-ui .composer .comp-box{border-radius:26px;border-color:var(--line);box-shadow:0 4px 20px rgba(0,0,0,.035);}
1417
1421
  .cli-ui .composer .comp-box:focus-within{border-color:var(--line-strong);box-shadow:0 6px 24px rgba(0,0,0,.055);}
1418
1422
  .cli-ui .composer .comp-toolbar{gap:6px;padding:6px 12px 12px;}
@@ -1549,7 +1553,7 @@
1549
1553
  the home indicator. The keyboard hints are mouse-era noise on a phone. */
1550
1554
  .cli-ui .app.mobile .composer{padding:10px 12px calc(12px + env(safe-area-inset-bottom));}
1551
1555
  .cli-ui .app.mobile .comp-area textarea{font-size:16px;}
1552
- .cli-ui .app.mobile .send{width:40px;height:40px;}
1556
+ .cli-ui .app.mobile .send{width:44px;height:44px;}
1553
1557
 
1554
1558
  /* Ask card options are tap targets — give them room. */
1555
1559
  .cli-ui .app.mobile .ask-opt{padding:10px 13px;}
package/src/styles.css CHANGED
@@ -54,12 +54,11 @@
54
54
  .cli-ui .cli-export{white-space:nowrap;}
55
55
  .cli-ui .comp-area textarea{min-width:0;}
56
56
  .cli-ui .comp-foot{gap:8px;padding:8px 10px;background:transparent;border-top:0;flex-wrap:wrap;}
57
- .cli-ui .cli-attach{display:inline-flex;align-items:center;gap:5px;min-height:32px;padding:0 8px;border-radius:7px;color:var(--ink-3);font-size:12px;}
57
+ .cli-ui .cli-attach{display:inline-flex;justify-content:center;align-items:center;gap:5px;min-width:44px;min-height:44px;padding:0 8px;border-radius:7px;color:var(--ink-3);font-size:12px;}
58
58
  .cli-ui .cli-attach:hover{background:var(--surface-2);color:var(--ink);}
59
- .cli-ui .cli-attach svg{width:15px;height:15px;}
60
- .cli-ui .send.cli-send{display:inline-flex;justify-content:center;gap:5px;width:auto;min-width:76px;padding:0 12px;font-size:12.5px;font-weight:600;}
61
- .cli-ui .send.cli-send:disabled{background:var(--surface-2);color:var(--ink-4);box-shadow:none;opacity:1;}
62
- .cli-ui .cli-export:focus-visible,.cli-ui .cli-attach:focus-visible,.cli-ui .send:focus-visible{outline:2px solid var(--accent);outline-offset:3px;}
59
+ .cli-ui .cli-attach svg{width:22px;height:22px;}
60
+ .cli-ui .comp-foot:has(.comp-model)>.spacer{display:none;}
61
+ .cli-ui .cli-export:focus-visible,.cli-ui .cli-attach:focus-visible{outline:2px solid var(--accent);outline-offset:3px;}
63
62
  .cli-ui .cli-task-control{height:34px;padding:0 10px;white-space:nowrap;}
64
63
  .cli-ui .cli-task-control:focus-visible{outline:2px solid var(--accent);outline-offset:3px;}
65
64
  @container cli-chat (max-width:560px){
@@ -495,7 +494,8 @@ cli-split{display:block;}
495
494
  .cli-ui .comp-area textarea,.cli-ui .ask-other,.cli-ui .bp-addr,.cli-ui .bp-dialog-in{font-size:16px;}
496
495
  .cli-ui .rail-ftab-x{width:32px;height:36px;opacity:1;}
497
496
  .cli-ui .cli-ref-x{width:32px;height:32px;}
498
- .cli-ui .cli-fv-btn,.cli-ui .cli-fv-back,.cli-ui .cli-fv-icon,.cli-ui .bp-nav,.cli-ui .cli-attach{min-height:36px;min-width:36px;}
497
+ .cli-ui .cli-fv-btn,.cli-ui .cli-fv-back,.cli-ui .cli-fv-icon,.cli-ui .bp-nav{min-height:36px;min-width:36px;}
498
+ .cli-ui .cli-attach{min-height:44px;min-width:44px;}
499
499
  .cli-ui .rail-collapse{min-width:36px;min-height:36px;}
500
500
  .cli-ui .cli-crumb{min-height:36px;}
501
501
  .cli-ui .cli-fv-selq{min-height:40px;}