import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";

test("conversation chrome is not selectable while message text remains selectable", async () => {
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");
  assert.match(css, /\.chat\{[^}]*user-select:none/);
  assert.match(css, /\.message\{[^}]*user-select:text/);
  assert.match(css, /\.chat textarea,.chat input\{[^}]*user-select:text/);
});

test("open conversations keep their title in the centered 60 percent of the back row", async () => {
  const [app, panel, appCss, conversationCss] = await Promise.all([
    readFile(new URL("../../App.jsx", import.meta.url), "utf8"),
    readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8"),
    readFile(new URL("../../app/app.css", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8")
  ]);

  assert.match(app, /const visibleConversationTitle = conversationDisplayTitle\(\{[\s\S]*?session: visibleSession,[\s\S]*?turns: visibleConversation,[\s\S]*?fallback: t\("chats\.new"\)/);
  assert.match(app, /title=\{visibleConversationTitle\} conversation=\{visibleConversation\}/);
  assert.match(panel, /<header className="conversation-header">[\s\S]*?<h1 className="conversation-title" title=\{title\}>\{title\}<\/h1>[\s\S]*?<\/header>/);
  assert.match(appCss, /\.conversation-header\{[^}]*position:absolute[^}]*left:0[^}]*right:0[^}]*display:grid[^}]*grid-template-columns:minmax\(48px,1fr\) minmax\(0,60%\) minmax\(48px,1fr\)[^}]*align-items:center[^}]*height:56px[^}]*padding:4px 10px[^}]*border:0[^}]*background:rgba\(16,17,20,\.92\)[^}]*backdrop-filter:blur\(12px\)/);
  assert.match(appCss, /\.conversation-header \.conversation-back\{[^}]*position:static/);
  assert.match(appCss, /\.conversation-title\{[^}]*overflow:hidden[^}]*text-overflow:ellipsis[^}]*white-space:nowrap[^}]*text-align:center/);
  assert.match(appCss, /html\[data-theme="light"\] \.conversation-header\{[^}]*background:rgba\(247,247,248,\.92\)/);
  assert.match(conversationCss, /\.event-list\{[^}]*padding:80px 12px 12px/);
});

test("conversation loading reuses the chat transition spinner instead of the empty prompt", async () => {
  const [app, panel, css] = await Promise.all([
    readFile(new URL("../../App.jsx", import.meta.url), "utf8"),
    readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8"),
    readFile(new URL("../chats/chats.css", import.meta.url), "utf8")
  ]);

  assert.match(app, /loading=\{app\.chatTransition \? false : conversation\.loading\}/);
  assert.match(await readFile(new URL("./useConversation.js", import.meta.url), "utf8"), /const loading = Boolean\(session\?\.id && \(state\.sessionId !== session\.id \|\| state\.loading\)\)/);
  assert.match(panel, /loading,/);
  assert.match(panel, /loading && <div className="conversation-loading" role="status" aria-busy="true" aria-label=\{t\("transition\.opening"\)\}/);
  assert.match(panel, /<span className="chat-transition-spinner" aria-hidden="true"><\/span>/);
  assert.match(panel, /\{!loading && visibleTurns\.map/);
  assert.match(panel, /!loading && !transitionActive && session && conversation\.length === 0/);
  assert.match(css, /\.chat-transition-spinner/);
});

test("workspace attachment uses a compact neutral prompt directly above the composer", async () => {
  const [panel, css] = await Promise.all([
    readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8")
  ]);

  assert.doesNotMatch(panel, /className="workspace-required"/);
  assert.doesNotMatch(css, /\.workspace-required/);
  assert.match(panel, /const workspacePromptVisible = !loading && !transitionActive && Boolean\(session\) && !session\.workspaceId && !session\.projectless/);
  assert.match(panel, /className=\{`chat\$\{workspacePromptVisible \? " chat--workspace-prompt" : ""\}`\}/);
  assert.match(panel, /className="composer-workspace-prompt"/);
  assert.ok(panel.indexOf('className="composer-workspace-prompt"') > panel.indexOf('className="composer-quick-options"'));
  assert.ok(panel.indexOf('className="composer-workspace-prompt"') < panel.indexOf('className={`composer-row'));
  assert.match(css, /\.composer-workspace-prompt\{[^}]*display:flex[^}]*width:min\(100%,560px\)[^}]*background:#202329[^}]*color:#aeb4be/);
  assert.match(css, /\.composer-workspace-prompt button\{[^}]*min-height:48px/);
  assert.match(css, /\.chat--workspace-prompt \.event-list\{[^}]*padding-bottom:calc\(214px \+ env\(safe-area-inset-bottom\)\)/);
  assert.match(css, /html\[data-theme="light"\] \.app-shell \.composer-workspace-prompt\{[^}]*background:#fff!important[^}]*color:#57606a!important/);
});

test("light mode keeps assistant answers ahead of muted work logs", async () => {
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(css, /html\[data-theme="light"\] \.app-shell \.message\.assistant\{[^}]*background:#fff!important[^}]*border:1px solid #d0d7de!important/);
  assert.match(css, /html\[data-theme="light"\] \.app-shell \.activity-card\{[^}]*color:#57606a!important/);
  assert.match(css, /html\[data-theme="light"\] \.app-shell \.activity-item\.commentary p\{[^}]*font-size:13px!important[^}]*font-weight:400!important/);
});

test("composer exposes model, permission, and polished tools menus", async () => {
  const panel = await readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8");
  const modelMenu = await readFile(new URL("./ComposerModelMenu.jsx", import.meta.url), "utf8");
  const permissionMenu = await readFile(new URL("./ComposerPermissionMenu.jsx", import.meta.url), "utf8");
  const toolsMenu = await readFile(new URL("./ComposerToolsMenu.jsx", import.meta.url), "utf8");
  const appCss = await readFile(new URL("../../app/app.css", import.meta.url), "utf8");
  const css = `${await readFile(new URL("./conversation.css", import.meta.url), "utf8")}\n${await readFile(new URL("./composer-menus.css", import.meta.url), "utf8")}`;

  assert.match(panel, /<ComposerModelMenu/);
  assert.match(panel, /<ComposerPermissionMenu/);
  assert.match(panel, /<ComposerToolsMenu/);
  assert.match(panel, /aria-label=\{t\("conversation\.back"\)\}/);
  assert.match(panel, /<BackIcon \/>/);
  assert.doesNotMatch(panel, />Back to Chats<\/button>/);
  assert.doesNotMatch(panel, /<select aria-label="Model"/);
  assert.doesNotMatch(panel, /<select aria-label="Approval mode"/);

  assert.match(css, /\.composer-quick-options/);
  assert.match(css, /\.composer-popover/);
  assert.match(css, /\.composer-tools-panel/);
  assert.match(css, /@media\(max-width:640px\)/);

  assert.match(modelMenu, /model-popover compact-menu/);
  assert.match(modelMenu, /className="composer-pill"[^>]*><span>\{modelButtonLabel/);
  assert.match(panel, /permissions=\{capabilities\?\.permissions \?\? \[\]\}/);
  assert.match(permissionMenu, /className="composer-pill"[^>]*><span>\{permissionLabel/);
  assert.match(modelMenu, /className="composer-popover model-picker-popover compact-menu"/);
  assert.match(permissionMenu, /selectablePermissionProfiles/);
  assert.match(permissionMenu, /t\("composer\.fileAccess"\)/);
  assert.match(permissionMenu, /t\("composer\.approvalMode"\)/);
  assert.match(permissionMenu, /onOption\("approvalPolicy", policy\)/);
  assert.match(permissionMenu, /<small>\{permissionDescription\(profile, t\)\}<\/small>/);
  assert.doesNotMatch(permissionMenu, /custom-option/);
  assert.match(permissionMenu, /permission-popover compact-menu/);
  assert.match(toolsMenu, /composer-tools-panel wide-menu/);
  for (const menu of [modelMenu, permissionMenu, toolsMenu]) {
    assert.match(menu, /useOutsideDismiss/);
    assert.match(menu, /ref=\{menuRef\}/);
  }
  assert.match(css, /--composer-menu-row:\s*5vh/);
  assert.match(css, /\.model-popover\.compact-menu,[\s\S]*?\.permission-popover\.compact-menu,[\s\S]*?\.model-picker-popover\.compact-menu\s*\{[^}]*width:\s*20vw/);
  assert.match(css, /\.tool-menu-row\s*\{[^}]*grid-template-columns:\s*32px minmax\(0, 1fr\) auto/);
  assert.match(css, /\.menu-option\.selected\s*\{[^}]*background:\s*#303030/);
  assert.match(css, /@media \(max-width: 640px\)[\s\S]*\.composer-pill\s*\{[^}]*padding:\s*0[^}]*border:\s*0[^}]*font-size:\s*12px/);
  assert.match(css, /\.composer-quick-options\s*\{[^}]*position:\s*absolute[^}]*bottom:\s*calc\(100% \+ 1px\)/);
  assert.match(css, /\.composer-quick-options\s*\{[^}]*left:\s*18px[^}]*right:\s*18px/);
  assert.match(css, /@media \(max-width: 640px\)[\s\S]*?\.composer-quick-options\s*\{[^}]*left:\s*14px[^}]*right:\s*14px/);
  assert.match(css, /\.composer-quick-options\s*\{[^}]*overflow-x:\s*auto/);
  assert.match(css, /\.composer-menu,\s*\.composer-active-chip\s*\{[^}]*flex:\s*0 0 auto/);
  assert.match(css, /\.composer-pill > span\s*\{[^}]*white-space:\s*nowrap/);
  assert.match(css, /\.event-list\s*\{[^}]*padding-bottom:\s*calc\(72px \+ env\(safe-area-inset-bottom\)\)/);
  assert.match(css, /\.event-list\s*\{[^}]*padding:\s*80px 12px 12px/);
  assert.match(css, /@media\(max-width:640px\)[\s\S]*\.event-list\{[^}]*padding:\s*80px 8px 10px/);
  assert.match(css, /\.composer-pill > span\s*\{[^}]*min-height:\s*28px/);
  assert.match(css, /\.composer-popover,[\s\S]*?\.composer-tools-panel\s*\{[^}]*padding:\s*8px/);
  assert.match(css, /\.menu-option,[\s\S]*?\.tool-menu-row\s*\{[^}]*padding:\s*4px 8px/);
  assert.match(css, /\.composer\{[^}]*border-top:0[^}]*background:transparent/);
  assert.match(css, /\.composer-icon-button\s*\{[^}]*border:\s*1px solid #3b3d42[^}]*border-radius:\s*50%[^}]*background:\s*#1b1d22/);
  assert.match(css, /@media\(max-width:640px\)[\s\S]*\.composer\{[^}]*box-shadow:none/);
  assert.match(css, /\.chat\{[^}]*overflow:hidden/);
  assert.match(appCss, /\.chat\.chat\{[^}]*grid-template-rows:1fr auto/);
  assert.match(appCss, /\.conversation-back\{[^}]*position:absolute[^}]*width:48px[^}]*border-radius:50%[^}]*background:#1b1d22/);
  assert.match(appCss, /\.conversation-back-icon::before/);
  assert.match(appCss, /\.conversation-back-icon::after/);
  assert.match(css, /\.empty-chat\{[^}]*margin:80px auto 32px/);
});

test("voice transcript stays isolated until stop or direct send", async () => {
  const [panel, conversationHook] = await Promise.all([
    readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8"),
    readFile(new URL("./useConversation.js", import.meta.url), "utf8")
  ]);

  assert.match(panel, /const \[voiceTranscript, setVoiceTranscript\] = useState\(""\)/);
  assert.match(panel, /const canSubmit = listening \? voiceTranscript\.trim\(\)\.length > 0 : !attachmentsPending && hasComposerContent/);
  assert.match(panel, /onTranscript: \(text\) => \{\s*voiceTranscriptRef\.current = text;\s*setVoiceTranscript\(text\);\s*\}/);
  assert.match(panel, /const completedMessage = appendVoiceTranscript\(voiceBaseMessageRef\.current, voiceTranscriptRef\.current\)/);
  assert.match(panel, /if \(shouldSend\) void requestSend\(completedMessage\);\s*else replaceComposerMessage\(completedMessage\)/);
  assert.match(panel, /voiceTranscript\.trim\(\) && <span className="voice-transcript">\{voiceTranscript\.trim\(\)\}<\/span>/);
  assert.doesNotMatch(panel, /onTranscript: \(text\) => \{[\s\S]{0,240}onMessageChange\(nextMessage\)/);
  assert.match(conversationHook, /function send\(messageOverride\)/);
  assert.match(conversationHook, /const text = typeof messageOverride === "string" \? messageOverride : state\.message/);
  assert.match(panel, /function cancelAccountHandoff\(\) \{\s*pendingSendMessageRef\.current = undefined;\s*setHandoffRequest\(null\);\s*\}/);
  assert.match(panel, /onCancel=\{cancelAccountHandoff\}/);
});

test("composer queues active-turn messages and exposes pending labels", async () => {
  const panel = await readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8");

  assert.match(panel, /const activeTurnRunning = session\?\.status === "running"/);
  assert.match(panel, /const activeTurnThinking = session\?\.status === "thinking"/);
  assert.match(panel, /const activeTurnInProgress = activeTurnRunning \|\| activeTurnThinking \|\| session\?\.status === "starting"/);
  assert.match(panel, /const controlsBusy =[^;]*activeTurnInProgress[^;]*session\.status === "waiting_approval"/);
  assert.match(panel, /const messageBlocked =[^;]*session\.status === "waiting_approval"/);
  assert.doesNotMatch(panel, /const messageBlocked =[^;]*session\.status === "running"/);
  assert.match(panel, /submitPending/);
  assert.match(panel, /setupErrorMessage\(error, "stop_failed"\)/);
  assert.match(panel, /session\?\.status === "starting" \? t\("activity\.started"\) : t\("activity\.running"\)/);
  assert.match(panel, /t\("conversation\.queueing"\)/);
  assert.match(panel, /t\("conversation\.sending"\)/);
  assert.doesNotMatch(panel, /session\?\.status === "running" \? "Steer" : "Send"/);
});

test("composer requires account handoff confirmation before sending", async () => {
  const [panel, dialog, css, app] = await Promise.all([
    readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8"),
    readFile(new URL("./AccountHandoffDialog.jsx", import.meta.url), "utf8"),
    readFile(new URL("./account-handoff.css", import.meta.url), "utf8"),
    readFile(new URL("../../App.jsx", import.meta.url), "utf8")
  ]);

  assert.match(app, /accountId: app\.accountId,[\s\S]*accounts: app\.accounts/);
  assert.match(panel, /selectedExecutionAccountId/);
  assert.match(panel, /shouldConfirmAccountHandoff\(session\?\.accountId, accountId\)/);
  assert.match(panel, /setHandoffRequest\(\{ sessionId: session\.id, fromAccountId: session\.accountId, toAccountId: accountId \}\)/);
  assert.match(panel, /accountHandoffRequestIsCurrent\(handoffRequest, session\?\.id, session\?\.accountId, accountId\)/);
  assert.match(panel, /<AccountHandoffDialog/);
  assert.match(dialog, /role="dialog" aria-modal="true"/);
  assert.match(dialog, /t\("conversation\.accountHandoffTokens"\)/);
  assert.match(dialog, /accountHandoffTokens[\s\S]*accountHandoffDescription/);
  assert.match(dialog, /className="account-handoff-continuity-note"/);
  assert.match(css, /\.account-handoff-dialog \.account-handoff-token-note\{[^}]*font-weight:700/);
  assert.match(css, /\.account-handoff-dialog \.account-handoff-continuity-note\{[^}]*font-weight:400/);
  assert.match(css, /\.account-handoff-backdrop\{[^}]*position:fixed/);
  assert.match(css, /html\[data-theme="light"\] \.app-shell \.account-handoff-actions button\{[^}]*border:1px solid #d0d7de!important;[^}]*background:#fff!important;[^}]*color:#1f2328!important/);
  assert.match(css, /html\[data-theme="light"\] \.app-shell \.account-handoff-confirm\{[^}]*border-color:#0969da!important;[^}]*background:#0969da!important;[^}]*color:#fff!important/);
  assert.match(css, /@media\(max-width:640px\)/);
});

test("capability load failures use a safe fallback without blocking the composer", async () => {
  const panel = await readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8");
  const source = await readFile(new URL("./useConversation.js", import.meta.url), "utf8");

  assert.match(source, /capabilityLoadFailure\(\)/);
  assert.doesNotMatch(source, /message:\s*error\.message/);
  assert.match(panel, /const messageBlocked = transitionActive \|\| !session \|\| !session\.workspaceId \|\| session\.status === "waiting_approval"/);
  assert.doesNotMatch(panel, /capabilities\?\.errors[\s\S]*messageBlocked/);
});

test("unknown workspace chats require an explicit project before attaching", async () => {
  const panel = await readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8");
  const picker = await readFile(new URL("./WorkspaceAttachPicker.jsx", import.meta.url), "utf8");
  const app = await readFile(new URL("../../App.jsx", import.meta.url), "utf8");

  assert.match(app, /workspaces=\{app\.workspaces\}/);
  assert.match(app, /workspaceId=\{app\.workspaceId\}/);
  assert.match(app, /onWorkspaceChange=\{app\.selectWorkspace\}/);
  assert.match(panel, /workspaces/);
  assert.match(panel, /workspaceId/);
  assert.match(panel, /onWorkspaceChange/);
  assert.match(panel, /<WorkspaceAttachPicker/);
  assert.match(picker, /listProjectFolders\(\)/);
  assert.match(picker, /project\.workspaceId\s*\? project\.workspaceId\s*:\s*await onRegisterProject\(project\.name\)/);
  assert.match(picker, /await onAttach\(selectedWorkspaceId\)/);
  assert.match(picker, /workspace-attach-sheet--exit/);
});

test("queued messages stay docked above the composer with compact overflow actions", async () => {
  const list = await readFile(new URL("./QueuedMessageList.jsx", import.meta.url), "utf8");
  const panel = await readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(panel, /className="composer-dock"[\s\S]*<QueuedMessageList[\s\S]*<div className="composer"/);
  assert.ok(panel.indexOf("<QueuedMessageList") > panel.indexOf('className="composer-dock"'));
  assert.match(panel, /<QueuedMessageList[^>]*canSteer=\{activeTurnRunning\}/);
  assert.match(list, /aria-label=\{t\("conversation\.queuedActions"\)\}/);
  assert.match(list, /t\("conversation\.editMessage"\)/);
  assert.match(list, /const canSteerItem = canSteer && \(item\.attachments\?\.length \?\? 0\) === 0 && \(item\.skills\?\.length \?\? 0\) === 0/);
  assert.match(list, /canSteerItem && <button[\s\S]*t\("conversation\.sendAsSteer"\)/);
  assert.match(list, /t\("conversation\.cancelMessage"\)/);
  assert.match(list, /queued-action-error/);
  assert.match(list, /const queueIdentity = messages\.map\(\(item\) => item\.id\)\.join\("\|"\)/);
  assert.match(list, /useEffect\(\(\) => setActionError\(""\), \[queueIdentity\]\)/);
  assert.match(css, /\.composer-dock\{[^}]*min-height:0/);
  assert.match(css, /\.queued-messages\{[^}]*position:absolute[^}]*right:10px[^}]*bottom:calc\(100% \+ 56px\)/);
  assert.match(css, /\.queued-messages\{[^}]*width:max-content[^}]*max-width:calc\(100vw - 20px\)/);
  assert.doesNotMatch(css, /\.queued-messages\{[^}]*padding:8px 10px 38px/);
  assert.match(css, /\.queued-message\{[^}]*width:fit-content[^}]*min-height:30px[^}]*max-width:min\(82vw,720px\)[^}]*border-radius:999px/);
});

test("composer uploads every accepted image selected in one picker action", async () => {
  const panel = await readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8");
  const toolsMenu = await readFile(new URL("./ComposerToolsMenu.jsx", import.meta.url), "utf8");
  const source = await readFile(new URL("./useConversation.js", import.meta.url), "utf8");

  assert.match(panel, /type="file" accept="image\/\*" multiple/);
  assert.match(panel, /<ComposerToolsMenu busy=\{controlsBusy\} uploadDisabled=\{uploadDisabled\}/);
  assert.match(toolsMenu, /disabled=\{uploadDisabled\} onClick=\{\(\) => \{ closeMenu\(\); fileRef\.current\?\.click\(\); \}\}/);
  assert.match(panel, /const uploadDisabled = transitionActive \|\| !session \|\| !session\.workspaceId/);
  assert.match(panel, /selectAttachmentUploads\(attachments, Array\.from\(event\.target\.files \?\? \[\]\)\)/);
  assert.match(panel, /for \(const file of selection\.accepted\) onUploadAttachment\(file\)/);
  assert.match(panel, /\.catch\(\(error\) => setSendError\(setupErrorMessage\(error, "upload_failed"\)\)\)/);
  assert.match(source, /const localId = crypto\.randomUUID\(\)/);
  assert.match(source, /setAttachments\(\(current\) => \[\.\.\.current, localAttachment\]\)/);
  assert.match(source, /setAttachments\(\(current\) => current\.map\(\(item\) => item\.id === localId/);
});

test("composer renders uploaded images as previews with delete controls", async () => {
  const panel = await readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(panel, /className="composer-attachments"/);
  assert.match(panel, /className="composer-attachment-preview"/);
  assert.match(panel, /src=\{item\.previewUrl\}/);
  assert.match(panel, /aria-label=\{t\("conversation\.removeImage", \{ name: item\.name \|\| t\("conversation\.image"\) \}\)\}/);
  assert.match(panel, /<CloseIcon \/>/);
  assert.doesNotMatch(panel, /composer-attachment-remove-icon/);
  assert.doesNotMatch(panel, />Delete<\/button>/);
  assert.match(css, /\.composer-attachments\{[^}]*display:flex/);
  assert.match(css, /\.composer-attachment-card\{[^}]*position:relative/);
  assert.match(css, /\.composer-attachment-preview\{[^}]*object-fit:cover/);
  assert.match(css, /\.composer-attachment-remove\{[^}]*position:absolute[^}]*top:-6px[^}]*right:-6px/);
  assert.doesNotMatch(css, /\.composer-attachment-remove-icon\{/);
});

test("composer can send attachment-only messages and clears previews optimistically", async () => {
  const panel = await readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8");
  const source = await readFile(new URL("./useConversation.js", import.meta.url), "utf8");

  assert.match(panel, /const attachmentsPending = attachments\.some\(\(item\) => item\.pending\)/);
  assert.match(panel, /const hasComposerContent = composerHasText \|\| attachments\.length > 0/);
  assert.match(panel, /const canSubmit = listening \? voiceTranscript\.trim\(\)\.length > 0 : !attachmentsPending && hasComposerContent/);
  assert.match(panel, /const showVoiceButton = !activeTurnInProgress && voiceSupported && !hasComposerContent && !listening/);
  assert.match(panel, /disabled=\{stopActionVisible \? stopPending : \(stopPending \|\| Boolean\(submitPending\) \|\| messageBlocked \|\| !canSubmit\)\}/);
  assert.match(source, /const submittedAttachments = attachments/);
  assert.match(source, /const submittedSkills = selectedSkills/);
  assert.match(source, /setAttachments\(\[\]\)/);
  assert.match(source, /setSelectedSkills\(\[\]\)/);
  assert.match(source, /attachmentCount: submittedAttachments\.length/);
  assert.match(source, /attachments: submittedAttachments/);
  assert.match(source, /selectedSkills: submittedSkills/);
  assert.match(source, /setAttachments\(\(current\) => \[\.\.\.submittedAttachments, \.\.\.current\]\)/);
  assert.match(source, /setSelectedSkills\(\(current\) => \[\.\.\.submittedSkills, \.\.\.current\]\)/);
  assert.doesNotMatch(source, /revokeAttachmentPreviews\(submittedAttachments\)/);
});

test("composer limits multi-image selections while preserving accepted files", async () => {
  const panel = await readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8");

  assert.match(panel, /selectAttachmentUploads\(attachments, Array\.from\(event\.target\.files \?\? \[\]\)\)/);
  assert.match(panel, /event\.target\.value = ""/);
  assert.match(panel, /for \(const file of selection\.accepted\) onUploadAttachment\(file\)/);
  assert.match(panel, /if \(selection\.rejected\.length\) setSendError/);
  assert.match(panel, /session\?\.attachmentUploadFailureCount/);
  assert.match(panel, /conversation\.uploadPartial/);
});

test("active plan and plugin selections appear beside permission controls", async () => {
  const panel = await readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./composer-menus.css", import.meta.url), "utf8");

  assert.match(panel, /composerOptions\.planMode && <button className="composer-pill composer-active-chip"/);
  assert.match(panel, /selectedSkills\.map\(\(skill\) => <button className="composer-pill composer-active-chip"/);
  assert.match(panel, /<span>\{t\("conversation\.plan"\)\} <CloseIcon \/><\/span>/);
  assert.match(panel, /<span>\{skill\.displayName \|\| skill\.name\} <CloseIcon \/><\/span>/);
  assert.match(panel, /onClick=\{\(\) => onComposerOption\("planMode", false\)\}/);
  assert.match(panel, /onClick=\{\(\) => onToggleSkill\(skill\)\}/);
  assert.match(css, /\.composer-active-chip\s*\{[^}]*min-height:\s*56px/);
  assert.match(css, /\.composer-active-chip\s*\{[^}]*align-items:\s*center/);
  assert.doesNotMatch(css, /\.composer-active-chip::before/);
  assert.doesNotMatch(css, /\.composer-active-chip::after/);
});

test("composer uses a multiline auto-growing textarea with explicit send shortcuts", async () => {
  const input = await readFile(new URL("./ComposerTextInput.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(input, /<textarea className="composer-input"/);
  assert.match(input, /shouldSubmitComposerKey\(event, \{ compact: usesCompactComposerKeyboard\(\) \}\)/);
  assert.match(input, /event\.preventDefault\(\)/);
  assert.match(css, /\.composer-input\{[^}]*max-height:60dvh[^}]*resize:none/);
});

test("composer action controls use icon-only visuals with accessible labels", async () => {
  const panel = await readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8");
  const toolsMenu = await readFile(new URL("./ComposerToolsMenu.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(panel, /const activeTurnRunning = session\?\.status === "running"/);
  assert.match(panel, /const activeTurnThinking = session\?\.status === "thinking"/);
  assert.match(panel, /const activeTurnInProgress = activeTurnRunning \|\| activeTurnThinking \|\| session\?\.status === "starting"/);
  assert.match(panel, /const \[inputFocused, setInputFocused\] = useState\(false\)/);
  assert.match(panel, /const composerEngaged = inputFocused \|\| hasComposerContent/);
  assert.match(panel, /const stopActionVisible = activeTurnInProgress\s*&& \(session\?\.status === "starting" \|\| submitPending !== "send"\)\s*&& !hasComposerContent && !listening/);
  assert.doesNotMatch(panel, /const stopActionVisible = activeTurnInProgress && !composerEngaged/);
  assert.match(panel, /const actionLabel = stopActionVisible \? \(stopPending \? t\("conversation\.stopping"\) : t\("conversation\.stop"\)\) : submitLabel/);
  assert.match(panel, /aria-label=\{actionLabel\}/);
  assert.match(panel, /stopActionVisible \? <span className="composer-stop-icon composer-action-icon"/);
  assert.match(panel, /onFocus=\{\(\) => setInputFocused\(true\)\} onBlur=\{\(\) => setInputFocused\(false\)\}/);
  assert.match(panel, /onClick=\{\(\) => listening \? finishVoice\("send"\) : stopActionVisible \? void stop\(\) : void submit\(\)\}/);
  assert.match(panel, /disabled=\{stopActionVisible \? stopPending : \(stopPending \|\| Boolean\(submitPending\) \|\| messageBlocked \|\| !canSubmit\)\}/);
  assert.match(toolsMenu, /aria-label=\{t\("composer\.tools"\)\}/);
  assert.match(toolsMenu, /className="composer-plus-icon composer-action-icon" aria-hidden="true"/);
  assert.match(toolsMenu, /<PlusIcon className="composer-action-svg" \/>/);
  assert.doesNotMatch(toolsMenu, />\+<\/summary>/);
  assert.doesNotMatch(panel, /aria-label=\{submitLabel\}/);
  assert.match(panel, /className="composer-send-icon composer-action-icon" aria-hidden="true"/);
  assert.match(panel, /<SendIcon \/>/);
  assert.match(panel, /<StopIcon \/>/);
  assert.doesNotMatch(panel, />\{submitLabel\}<\/button>/);
  assert.match(css, /\.composer-action-icon\{[^}]*position:absolute[^}]*bottom:0[^}]*width:56px[^}]*height:56px/);
  assert.match(css, /\.composer-row\{[^}]*background:transparent/);
  assert.match(css, /\.composer-input-wrap\{[^}]*background:transparent/);
  assert.match(css, /\.composer-send \.composer-action-icon\{[^}]*background:#f4f4f4[^}]*color:#111/);
  assert.match(css, /\.composer-send \.composer-stop-icon\{[^}]*background:#f4f4f4[^}]*color:#111/);
  assert.match(css, /\.composer-action-svg\{[^}]*stroke:currentColor/);
  assert.match(css, /\.composer-stop-icon \.composer-action-svg\{[^}]*width:18px[^}]*height:18px[^}]*fill:currentColor[^}]*stroke:none/);
});

test("composer exposes ChatGPT-style voice input controls", async () => {
  const panel = await readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(panel, /createVoiceRecognition/);
  assert.match(panel, /const VOICE_METER_FRAME_MS = 50/);
  assert.doesNotMatch(panel, /void startVoiceMeter\(\)/);
  assert.match(panel, /voiceMeterTokenRef/);
  assert.match(panel, /voiceTranscriptRef/);
  assert.match(panel, /voiceCompletionActionRef/);
  assert.match(panel, /voiceBaseMessageRef/);
  assert.match(panel, /onTranscript: \(text\) =>/);
  assert.match(panel, /onDiagnostic: \(status\) => setVoiceDiagnostic\(status\)/);
  assert.match(panel, /const completedMessage = appendVoiceTranscript\(voiceBaseMessageRef\.current, voiceTranscriptRef\.current\)/);
  assert.match(panel, /if \(shouldSend\) void requestSend\(completedMessage\)/);
  assert.match(panel, /appendVoiceTranscript/);
  assert.match(panel, /supportsVoiceInput/);
  assert.match(panel, /const listening = voiceStatus === "listening"/);
  assert.match(panel, /const showVoiceButton = !activeTurnInProgress && voiceSupported && !hasComposerContent && !listening/);
  assert.match(panel, /const composerEngaged = inputFocused \|\| hasComposerContent/);
  assert.match(panel, /className=\{`composer-row \$\{listening \? "listening" : ""\}`\}/);
  assert.match(panel, /!listening && <ComposerToolsMenu/);
  assert.match(panel, /aria-label=\{t\("conversation\.startVoice"\)\}/);
  assert.match(panel, /setSendError\(t\(voiceInputErrorKey\(error\)\)\)/);
  assert.match(panel, /aria-label=\{t\("conversation\.stopVoice"\)\}/);
  assert.match(panel, /<VoiceWaveform bars=\{voiceBars\}/);
  assert.match(panel, /<div className={`composer-input-wrap \$\{listening \? "composer-input-wrap--listening" : ""\}`}>/);
  assert.match(panel, /listening \? <div className="composer-voice-surface">[\s\S]*<VoiceWaveform bars=\{voiceBars\} \/>[\s\S]*<button className="composer-voice-stop"/);
  assert.match(panel, /<span className="voice-transcript">\{voiceTranscript\.trim\(\)\}<\/span>/);
  assert.match(panel, /<span className="voice-diagnostic">\{voiceDiagnostic\}<\/span>/);
  assert.match(panel, /onFocus=\{\(\) => setInputFocused\(true\)\}/);
  assert.match(panel, /showVoiceButton \? <button className="composer-voice"/);
  assert.match(panel, /if \(listening\) return finishVoice\("send"\)/);
  assert.match(panel, /function finishVoice\(action = "draft"\)/);
  assert.match(panel, /voiceCompletionActionRef\.current = action/);
  assert.match(panel, /const shouldSend = voiceCompletionActionRef\.current === "send"/);
  assert.match(panel, /if \(shouldSend\) void requestSend\(completedMessage\)/);
  assert.match(panel, /onClick=\{\(\) => finishVoice\("draft"\)\}/);
  assert.match(panel, /listening \? finishVoice\("send"\)/);
  assert.doesNotMatch(panel, /voiceSubmitPendingRef/);
  assert.match(panel, /else replaceComposerMessage\(completedMessage\)/);
  assert.match(panel, /await onSend\(messageOverride\)/);
  assert.match(css, /\.voice-waveform\{[^}]*display:flex/);
  assert.match(css, /\.voice-transcript\{[^}]*overflow:hidden[^}]*text-overflow:ellipsis/);
  assert.match(css, /\.voice-waveform-bar\{[^}]*height:calc\(4px \+ var\(--voice-level\) \* 28px\)/);
  assert.match(css, /\.composer-row\.listening\{[^}]*grid-template-columns:minmax\(0,1fr\) 56px/);
  assert.match(css, /\.composer-row\.listening \.composer-input-wrap\{[^}]*grid-column:1/);
  assert.match(css, /\.composer-voice-surface\{[^}]*display:flex[^}]*height:56px[^}]*border-radius:28px[^}]*background:#30323b/);
  assert.match(css, /\.composer-voice-stop\{[^}]*position:relative[^}]*flex:0 0 56px/);
  assert.match(css, /\.composer-row\.listening \.composer-action-icon\{[^}]*top:50%[^}]*bottom:auto[^}]*translate\(-50%,-50%\)/);
  assert.match(css, /\.composer-voice-stop \.composer-action-icon\{[^}]*background:#ff4d5c/);
  assert.match(css, /\.composer-voice/);
  assert.match(css, /\.composer-voice-stop/);
});

test("sent image previews render above the user message bubble", async () => {
  const turn = await readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(turn, /<div className="message-attachments user-attachments"/);
  assert.match(turn, /<MessageBubble[^>]*side="user"/);
  assert.match(turn, /<div className="message user">\{message\.text\}<\/div>/);
  assert.ok(turn.indexOf('className="message-attachments user-attachments"') < turn.indexOf('className="message user"'));
  assert.match(css, /\.message-attachments\.user-attachments\{[^}]*justify-self:end/);
  assert.doesNotMatch(css, /\.message\.user:has\(\.message-attachments\)/);
});

test("sent image previews open an accessible message-scoped gallery", async () => {
  const [turn, gallery, css] = await Promise.all([
    readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8"),
    readFile(new URL("./ImageGallery.jsx", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8")
  ]);

  assert.match(turn, /<ImageGallery images=\{gallery\.images\} initialIndex=\{gallery\.index\}/);
  assert.match(turn, /onOpenGallery=\{\(images, index\) => setGallery\(\{ images, index \}\)\}/);
  assert.match(turn, /type="button" className="message-attachment-button"/);
  assert.match(gallery, /role="dialog" aria-modal="true"/);
  assert.match(gallery, /useBackLayer\(true, onClose, "image-gallery"/);
  assert.match(gallery, /event\.key === "ArrowLeft"/);
  assert.match(gallery, /event\.key === "ArrowRight"/);
  assert.match(gallery, /onPointerDown/);
  assert.match(gallery, /onPointerUp/);
  assert.match(gallery, /\{index \+ 1\} \/ \{images\.length\}/);
  assert.match(css, /\.image-gallery-overlay\{[^}]*position:fixed[^}]*inset:0/);
  assert.match(css, /\.image-gallery-image\{[^}]*object-fit:contain/);
});

test("turn renderer shows image-only messages but omits empty restored user content", async () => {
  const turn = await readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8");
  assert.match(turn, /\(section\.message\.text \|\| section\.message\.attachments\?\.length > 0\) && <UserMessage/);
  assert.match(turn, /message\.text && <MessageBubble/);
});

test("full access permission uses a subtle orange warning state", async () => {
  const permissionMenu = await readFile(new URL("./ComposerPermissionMenu.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./composer-menus.css", import.meta.url), "utf8");

  assert.match(permissionMenu, /selected === ":danger-full-access" \? "composer-menu full-access" : "composer-menu"/);
  assert.match(css, /\.composer-menu\.full-access \.composer-pill > span\s*\{[^}]*background:\s*#3a2814/);
  assert.match(css, /\.permission-popover \.menu-option\.full-access\.selected\s*\{[^}]*background:\s*#3a2814/);
});

test("composer tools menu closes after each completed menu action", async () => {
  const toolsMenu = await readFile(new URL("./ComposerToolsMenu.jsx", import.meta.url), "utf8");

  assert.match(toolsMenu, /const closeMenu = \(\) =>/);
  assert.match(toolsMenu, /onClick=\{\(\) => \{ closeMenu\(\); fileRef\.current\?\.click\(\); \}\}/);
  assert.match(toolsMenu, /onOption\("planMode", !planMode\); closeMenu\(\)/);
  assert.match(toolsMenu, /onToggleSkill\(skill\); closeMenu\(\)/);
});

test("activity log uses a compact disclosure and wraps long content", async () => {
  const card = await readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(card, /activityLogCount\(turn\)/);
  assert.match(card, /activityTitle\(turn, logCount, t\)/);
  assert.match(card, /activity-chevron/);
  assert.match(css, /\.activity-card\{[^}]*border:0[^}]*background:transparent/);
  assert.match(css, /\.activity-item>div\{[^}]*min-width:0/);
  assert.match(css, /\.activity-item p\{[^}]*overflow-wrap:anywhere/);
});

test("technical activity rows share file-change spacing without separators", async () => {
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(css, /\.activity-details\{[^}]*gap:4px[^}]*padding:8px 2px 2px/);
  assert.match(css, /:is\(\.activity-technical-group>summary,\.activity-file-group-button,\.activity-tool-call-button\)\{[^}]*min-height:48px[^}]*padding:6px 0[^}]*color:#9ca3ad[^}]*font-size:13px[^}]*font-weight:400/);
  assert.match(css, /html\[data-theme="light"\] \.app-shell \.activity-card \.activity-technical-group>summary,html\[data-theme="light"\] \.app-shell \.activity-card \.activity-file-group-button>span:not\(\.activity-file-icon\),html\[data-theme="light"\] \.app-shell \.activity-card \.activity-tool-call-button>span:first-child\{color:#6e7781!important\}/);
  assert.match(css, /\.activity-details>:is\(\.activity-technical-group,\.activity-file-group-button,\.activity-tool-call-result\)\+:is\(\.activity-technical-group,\.activity-file-group-button,\.activity-tool-call-result\)\{margin-top:-12px\}/);
  assert.doesNotMatch(css, /\.activity-file-group-button::(?:before|after)/);
});

test("activity log spans the conversation row instead of stopping mid-screen", async () => {
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(css, /\.activity-card\{[^}]*width:100%[^}]*max-width:100%[^}]*justify-self:stretch/);
});

test("activity commentary is emphasized over collapsed technical activity", async () => {
  const card = await readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(card, /className=\{`activity-item \$\{item\.kind === "agentMessage" \? "commentary" : "technical"\}`\}/);
  assert.match(css, /\.activity-item\.commentary p\{[^}]*color:#e7e9ee[^}]*font-size:15px[^}]*font-weight:600/);
  assert.match(css, /\.activity-item\.technical\{[^}]*color:#8f96a3[^}]*font-size:12px/);
  assert.match(css, /\.activity-command>summary\{[^}]*font-weight:500/);
  assert.match(css, /\.activity-command-group>summary\{[^}]*font-weight:500/);
});

test("technical activity stays compact until expanded and cannot overflow the viewport", async () => {
  const card = await readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(card, /<details className="activity-technical-group"/);
  assert.match(card, /technicalGroupLabel\(technicalItems, t\)/);
  assert.match(css, /\.activity-card>summary\{/);
  assert.match(css, /\.activity-item\{[^}]*max-width:100%/);
  assert.match(css, /\.activity-technical-group>summary\{[^}]*align-items:center[^}]*overflow:hidden[^}]*white-space:nowrap[^}]*text-overflow:ellipsis/);
  assert.match(css, /\.activity-technical-group\[open\]>summary\{[^}]*white-space:normal[^}]*overflow-wrap:anywhere/);
});

test("consecutive commands render inside an expandable technical group", async () => {
  const card = await readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(card, /groupTechnicalActivity\(activityItems\)/);
  assert.match(card, /className="activity-technical-group"/);
  assert.match(card, /technicalGroupLabel\(technicalItems, t\)/);
  assert.match(card, /technicalItems\.map\(\(item\) => <ActivityItem item=\{item\} onOpenToolCall=\{onOpenToolCall\} t=\{t\}/);
  assert.match(css, /\.activity-technical-group-items\{[^}]*display:grid/);
});

test("adjacent command and file activity keeps commands expandable and files directly openable", async () => {
  const card = await readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(card, /groupTechnicalActivity\(activityItems\)/);
  assert.match(card, /technicalGroupLabel\(technicalItems, t\)/);
  assert.match(card, /className="activity-technical-group"/);
  assert.match(card, /className="activity-technical-group-items"/);
  assert.match(card, /className="activity-file-group-button"/);
  assert.match(card, /className="activity-item-summary"/);
  assert.match(css, /:is\(\.activity-technical-group>summary,\.activity-file-group-button,\.activity-tool-call-button\)\{[^}]*font-weight:400/);
  assert.match(css, /\.activity-technical-group-items\{[^}]*display:grid/);
  assert.match(css, /\.activity-item-summary\{[^}]*font-weight:400/);
});

test("activity rows use dot markers and file changes use the compact code label", async () => {
  const card = await readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(card, /const iconFor = \(kind\) => "•"/);
  assert.match(card, /turn\.activity\.plan\.map\(\(step, index\) => <li className=\{step\.status\} key=\{`\$\{step\.step\}-\$\{index\}`\}><span>•<\/span>/);
  assert.match(card, /turn\.approvals\.filter\(\(a\) => a\.status !== "pending"\)\.map\(\(a\) => <div className="activity-item" key=\{a\.id\}><span>•<\/span>/);
  assert.match(card, /\["mcpToolCall", "dynamicToolCall", "webSearch"\]\.includes\(item\.kind\)/);
  assert.match(card, /<TechnicalActivityIcon \/>/);
  assert.match(card, /<TechnicalActivityIcon className="activity-file-icon activity-technical-icon" \/>/);
  assert.match(css, /\.activity-technical-icon\{[^}]*display:block[^}]*width:22px[^}]*height:18px[^}]*stroke-width:1\.8/);
  assert.match(css, /:is\(\.activity-technical-group>summary,\.activity-file-group-button,\.activity-tool-call-button\)\{[^}]*font-size:13px[^}]*font-weight:400/);
  assert.match(css, /\.activity-file-direct-button\{[^}]*font-size:13px[^}]*font-weight:400/);
});

test("activity briefing bullets sit close to and align with the first text row", async () => {
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(css, /\.activity-plan li,\.activity-item\{[^}]*gap:7px/);
  assert.match(css, /\.activity-plan li>span,\.activity-item>span\{[^}]*min-width:16px[^}]*line-height:1\.55[^}]*text-align:center/);
  assert.match(css, /\.activity-item\.commentary\{[^}]*align-items:baseline/);
  assert.match(css, /\.activity-item\.commentary \.activity-result\{[^}]*margin:0[^}]*padding:0[^}]*background:transparent[^}]*font:inherit[^}]*overflow:visible/);
});

test("outer and nested activity disclosures rotate only their own chevrons", async () => {
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(css, /\.activity-card\[open\]>summary \.activity-chevron\{/);
  assert.match(css, /\.activity-technical-group\[open\]>summary \.activity-chevron\{/);
});

test("file activity exposes a safe aggregate patch button through conversation props", async () => {
  const [card, turn, panel] = await Promise.all([
    readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8"),
    readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8"),
    readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8")
  ]);

  assert.match(panel, /changedFiles/);
  assert.match(panel, /onOpenChangedFile/);
  assert.match(turn, /changedFiles=\{changedFiles\}/);
  assert.match(card, /className="activity-file-group-button"/);
  assert.match(card, /t\("activity\.filesChanged", \{ count: files\.length \}\)/);
  assert.match(card, /onClick=\{\(\) => onOpenChangedFile\(files\)\}/);
  assert.doesNotMatch(card, /files\.map\(\(file\) => <button/);
});

test("file-only activity opens the diff sheet without a nested disclosure step", async () => {
  const [card, css] = await Promise.all([
    readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8")
  ]);

  assert.match(card, /const directFileFiles = directFileActivityFiles\(activityGroups, changedFiles\)/);
  assert.match(card, /className=\{`activity-card activity-file-direct-button/);
  assert.match(card, /onClick=\{\(\) => onOpenChangedFile\(directFileFiles\)\}/);
  assert.match(card, /directFileFiles\.length > 0/);
  assert.match(css, /\.activity-file-direct-button\{/);
});

test("mixed command and file activity keeps the log and exposes a direct file button inside it", async () => {
  const card = await readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8");

  assert.match(card, /const technicalItems = items\.filter\(\(item\) => item\.kind !== "fileChange"\)/);
  assert.match(card, /technicalItems\.length > 0 && <details className="activity-technical-group"/);
  assert.match(card, /files\.length > 0 && <button className="activity-file-group-button"/);
  assert.match(card, /if \(!items\.every\(\(item\) => item\.kind === "fileChange"\)\) return \[\]/);
});

test("file activity passes its patch file list to the diff sheet", async () => {
  const card = await readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8");

  assert.match(card, /const files = activityFiles\(items, changedFiles\)/);
  assert.match(card, /onOpenChangedFile\(files\)/);
});

test("tool activity uses a concise name and always opens its details", async () => {
  const [card, css] = await Promise.all([
    readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8")
  ]);

  assert.match(card, /const \[openToolCall, setOpenToolCall\] = useState\(null\)/);
  assert.match(card, /function ToolCallSheet/);
  assert.match(card, /<button type="button" className="activity-tool-call-button" onClick=\{\(\) => onOpenToolCall\(item\)\}><span>\{toolCallTitle\(item, t\)\}<\/span><ForwardIcon className="activity-tool-call-arrow" \/>/);
  assert.doesNotMatch(card, /className="activity-tool-call-button"[^\n]*<ActivityResult item=\{item\} \/>/);
  assert.match(card, /className="tool-call-sheet-body"[\s\S]*<ActivityResult item=\{item\} \/>/);
  assert.match(card, /return `\$\{tool\} \$\{t\("activity\.call"\)\} \$\{status\}`/);
  assert.doesNotMatch(card, /activity-tool-call-label/);
  assert.doesNotMatch(card, /activity-file-icon">&lt;\/&gt;<\/span><span>\{toolCallTitle/);
  assert.match(card, /aria-label=\{t\("activity\.toolCall"\)\}/);
  assert.match(card, /item\.toolCall\?\.arguments/);
  assert.match(css, /\.activity-tool-call-button\{[^}]*grid-template-columns:minmax\(0,1fr\) auto/);
  assert.match(css, /\.activity-tool-call-arrow,\.activity-file-forward-icon\{[^}]*width:18px[^}]*stroke-width:1\.8/);
  assert.match(css, /html\[data-theme="light"\] \.app-shell :is\(\.activity-file-direct-button,\.activity-file-group-button,\.activity-tool-call-button\)\{[^}]*background:transparent!important[^}]*border:0!important/);
  assert.match(css, /\.tool-call-sheet-backdrop\{/);
  assert.match(css, /\.tool-call-sheet\{[^}]*grid-template-rows:auto auto minmax\(0,1fr\)/);
  assert.match(css, /\.tool-call-sheet-body\{[^}]*min-height:0[^}]*overflow:auto/);
  assert.match(css, /\.tool-call-arguments pre\{/);
});

test("command rows stay collapsed when their technical group opens", async () => {
  const [card, css] = await Promise.all([
    readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8")
  ]);

  assert.match(card, /<details className="activity-command"><summary>\{summary\}<\/summary>/);
  assert.doesNotMatch(card, /<details className="activity-command"[^>]*\sopen=/);
  assert.match(css, /\.activity-command>summary\{[^}]*text-overflow:ellipsis[^}]*white-space:nowrap/);
  assert.match(css, /\.activity-command\[open\]>summary\{[^}]*white-space:pre-wrap/);
  assert.match(css, /\.activity-technical-group-items\{[^}]*padding:8px 0 10px 22px/);
});

test("generated activity images use compact thumbnails with an accessible lightbox", async () => {
  const [card, css] = await Promise.all([
    readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8")
  ]);

  assert.match(card, /function GeneratedImagePreview/);
  assert.match(card, /className="activity-generated-image-button"/);
  assert.match(card, /className="activity-image-lightbox-backdrop"/);
  assert.match(card, /role="dialog" aria-modal="true"/);
  assert.match(card, /useBackLayer\(Boolean\(open\), closePreview, "generated-image-preview"\)/);
  assert.match(css, /\.activity-generated-image\{[^}]*width:160px[^}]*height:160px[^}]*object-fit:cover/);
  assert.match(css, /\.activity-image-lightbox-image\{[^}]*max-width:min\(92vw,960px\)[^}]*max-height:88dvh[^}]*object-fit:contain/);
});

test("activity log hides technical commentary labels", async () => {
  const card = await readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8");

  assert.match(card, /visibleActivityItems\(turn\.activity\.items\)/);
  assert.match(card, /item\.kind !== "agentMessage"/);
});

test("live activity opens initially, respects manual collapse, and closes when the turn finishes", async () => {
  const card = await readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8");

  assert.match(card, /const ACTIVE_ACTIVITY_STATUSES = new Set\(\["thinking", "running", "waiting_approval", "waiting_response"\]\)/);
  assert.match(card, /const \[activityOpen, setActivityOpen\] = useState\(\(\) => ACTIVE_ACTIVITY_STATUSES\.has\(turn\.status\)\)/);
  assert.match(card, /if \(!ACTIVE_ACTIVITY_STATUSES\.has\(turn\.status\)\) setActivityOpen\(false\)/);
  assert.match(card, /<details className=\{`activity-card \$\{state\.className\}`\} open=\{activityOpen\} onToggle=\{\(event\) => setActivityOpen\(event\.currentTarget\.open\)\}>/);
});

test("conversation bottom padding follows the actual floating composer height", async () => {
  const [panel, css] = await Promise.all([
    readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8")
  ]);

  assert.match(panel, /const composerDock = chat\?\.querySelector\("\.composer-dock"\)/);
  assert.match(panel, /new ResizeObserver\(updateComposerDockHeight\)/);
  assert.match(panel, /style\.setProperty\("--composer-dock-height", `\$\{Math\.ceil\(height\)\}px`\)/);
  assert.match(css, /\.chat\{[^}]*--composer-dock-height:132px/);
  assert.match(css, /\.event-list\{[^}]*padding-bottom:calc\(var\(--composer-dock-height\) \+ 20px\)/);
  assert.doesNotMatch(css, /\.event-list\{padding-bottom:calc\(144px \+ env\(safe-area-inset-bottom\)\)\}/);
});

test("conversation offers a centered fast scroll-to-latest control away from the bottom", async () => {
  const [panel, icons, css, i18n] = await Promise.all([
    readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8"),
    readFile(new URL("../../shared/ActionIcons.jsx", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8"),
    readFile(new URL("../../shared/i18n.js", import.meta.url), "utf8")
  ]);

  assert.match(icons, /export function LatestMessageIcon[\s\S]*<ArrowDown/);
  assert.match(panel, /const \[showScrollLatest, setShowScrollLatest\] = useState\(false\)/);
  assert.match(panel, /setShowScrollLatest\(!nearBottom\)/);
  assert.match(panel, /conversationScrollTop\(start, target, elapsed, 220\)/);
  assert.match(panel, /followsLatestRef\.current = true/);
  assert.match(panel, /showScrollLatest && <button[^>]*className=\{`conversation-scroll-latest\$\{activeTurnInProgress \? " is-live" : ""\}`\}[^>]*aria-label=\{t\("conversation\.scrollToLatest"\)\}/);
  assert.match(panel, /<LatestMessageIcon \/>/);
  assert.match(css, /\.conversation-scroll-latest\{[^}]*position:absolute[^}]*left:50%[^}]*bottom:calc\(var\(--composer-dock-height\) \+ 60px\)[^}]*width:48px[^}]*height:48px/);
  assert.match(css, /\.conversation-scroll-latest\.is-live\{[^}]*bottom:calc\(var\(--composer-dock-height\) \+ 108px\)/);
  assert.match(css, /\.conversation-scroll-latest:active:not\(:disabled\)\{[^}]*transform:translateX\(-50%\) scale\(\.97\)/);
  assert.match(i18n, /"conversation\.scrollToLatest": "Go to latest message"/);
  assert.match(i18n, /"conversation\.scrollToLatest": "최신 메시지로 이동"/);
});

test("an idle direct send starts its new turn below the header while queued sends keep the current view", async () => {
  const [panel, css] = await Promise.all([
    readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8")
  ]);

  assert.match(panel, /shouldAnchorConversationSend\(session\?\.status\)/);
  assert.match(panel, /directSendAnchor = \{[\s\S]*?sessionId: session\.id/);
  assert.match(panel, /pendingDirectSendAnchorRef\.current = directSendAnchor/);
  assert.match(panel, /conversationTurnAnchorScrollTop\(\{[\s\S]*?currentScrollTop: eventList\.scrollTop,[\s\S]*?viewportTop: viewportRect\.top,[\s\S]*?turnTop: latestTurn\.getBoundingClientRect\(\)\.top/);
  assert.match(panel, /conversationScrollTop\(start, target, elapsed, 220\)/);
  assert.match(panel, /conversationDirectSendSpacerHeight\(\{[\s\S]*?targetScrollTop: target,[\s\S]*?scrollHeight: eventList\.scrollHeight,[\s\S]*?clientHeight: eventList\.clientHeight/);
  assert.match(panel, /eventList\.style\.setProperty\("--direct-send-response-space", `\$\{spacerHeight\}px`\)/);
  assert.match(panel, /directSendScrollGuardRef\.current = conversationDirectSendScrollGuard\(\{[\s\S]*?reservedSpaceHeight: spacerHeight,[\s\S]*?sessionId: session\.id,[\s\S]*?anchorScrollTop: target,[\s\S]*?initialScrollHeight: eventList\.scrollHeight,[\s\S]*?initialAvailableSpace/);
  assert.match(panel, /conversationDirectSendMaxScrollTop\(\{[\s\S]*?anchorScrollTop: directSendGuard\.anchorScrollTop,[\s\S]*?initialScrollHeight: directSendGuard\.initialScrollHeight,[\s\S]*?currentScrollHeight: viewport\.scrollHeight,[\s\S]*?initialAvailableSpace: directSendGuard\.initialAvailableSpace/);
  assert.match(panel, /followsLatestRef\.current = false/);
  assert.match(panel, /className=\{`event-list\$\{directSendSpaceSessionId === session\?\.id \? " direct-send-response-space" : ""\}`\}/);
  assert.match(panel, /if \(directSendSpaceSessionId && directSendSpaceSessionId !== session\?\.id\) \{[\s\S]*?setDirectSendSpaceSessionId\(null\)[\s\S]*?directSendScrollGuardRef\.current = null/);
  assert.match(panel, /shouldReleaseConversationDirectSendGuard\(\{[\s\S]*?guardSessionId: directSendGuard\?\.sessionId,[\s\S]*?status: session\?\.status[\s\S]*?\}\)\) return;[\s\S]*?\}, \[session\?\.id, session\?\.status, conversation\.length\]\)/);
  assert.match(css, /\.event-list\{[^}]*padding:80px 12px 12px/);
  assert.match(css, /\.event-list\.direct-send-response-space::after\{[^}]*min-height:var\(--direct-send-response-space,0px\)/);
  assert.match(css, /@media\(max-width:640px\)\{\.event-list\{[^}]*padding:80px 8px 10px/);
});

test("interrupted activity cards do not fall back to running state", async () => {
  const card = await readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(card, /status === "interrupted" \? \{ label: t\("activity\.interrupted"\), className: "interrupted" \}/);
  assert.match(css, /\.activity-card\.interrupted \.activity-state i\{[^}]*background:#8f96a3/);
});

test("turns hide empty activity cards after commentary questions are promoted", async () => {
  const turn = await readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8");

  assert.match(turn, /sectionLogCount > 0/);
  assert.match(turn, /showActivity && <ActivityCard/);
});

test("steering messages do not create a second empty activity card after existing logs", async () => {
  const turn = await readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8");

  assert.match(turn, /const totalLogCount = activityLogCount\(turn\)/);
  assert.match(turn, /const sectionLogCount = activityLogCount\(sectionTurn\)/);
  assert.match(turn, /showActivity: sectionLogCount > 0 \|\| \(\(isActive \|\| isStopped\) && totalLogCount === 0\)/);
});

test("steering messages render in event order between activity slices", async () => {
  const [turn, activityCard] = await Promise.all([
    readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8"),
    readFile(new URL("./activity-card.js", import.meta.url), "utf8")
  ]);

  assert.match(turn, /const sections = turnSections\(turn\)/);
  assert.match(turn, /sections\.map\(\(section\) => <TurnSection/);
  assert.match(turn, /activityItemsBetween\(turn\.activity\.items \?\? \[\], message\.createdAt, nextMessage\?\.createdAt\)/);
  assert.match(activityCard, /const lower = Number\.isFinite\(startAt\) \? startAt : Number\.NEGATIVE_INFINITY/);
  assert.match(activityCard, /Number\.isFinite\(item\.updatedAt\)/);
  assert.match(activityCard, /return activityAt >= lower && \(endAt == null \|\| activityAt < endAt\)/);
});

test("assistant replies render safe GitHub-flavored Markdown without changing user messages", async () => {
  const [turn, markdown, css] = await Promise.all([
    readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8"),
    readFile(new URL("./AssistantMarkdown.jsx", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8")
  ]);

  assert.match(turn, /<AssistantMarkdown>\{turn\.assistantText\}<\/AssistantMarkdown>/);
  assert.match(turn, /className="message user"[^>]*>\{message\.text\}<\/div>/);
  assert.match(markdown, /import ReactMarkdown from "react-markdown"/);
  assert.match(markdown, /import remarkGfm from "remark-gfm"/);
  assert.match(markdown, /remarkPlugins=\{\[remarkGfm\]\}/);
  assert.match(markdown, /pre: \(\{ node: _node, children, \.\.\.props \}\) =>/);
  assert.match(markdown, /return text\.trim\(\) \? <CodeBlock \{\.\.\.props\} text=\{text\}>\{children\}<\/CodeBlock> : null/);
  assert.doesNotMatch(markdown, /rehypeRaw|dangerouslySetInnerHTML/);
  assert.match(markdown, /target="_blank"/);
  assert.match(markdown, /rel="noreferrer noopener"/);
  assert.match(markdown, /isExternalWebUrl\(href\)/);
  assert.match(markdown, /className="markdown-unavailable-link"/);
  assert.match(markdown, /img: \(\{ node: _node, src = "", \.\.\.props \}\) =>/);
  assert.match(markdown, /if \(!artifact\) return <span className="markdown-unavailable-image"/);
  assert.match(markdown, /return <img \{\.\.\.props\} src=\{artifact\.url\} loading="lazy"/);
  assert.doesNotMatch(markdown, /artifact\?\.url \?\? src/);
  assert.match(css, /\.message\.assistant\.markdown-body pre\{[^}]*overflow-x:hidden/);
  assert.match(css, /\.message\.assistant\.markdown-body \.table-scroll\{[^}]*overflow-x:auto/);
  assert.match(css, /\.message\.assistant\.markdown-body img\{[^}]*max-width:100%[^}]*height:auto[^}]*object-fit:contain/);
  assert.match(css, /\.markdown-unavailable-link\{[^}]*text-decoration:none[^}]*cursor:default/);
  assert.match(css, /\.markdown-unavailable-image\{[^}]*display:grid[^}]*min-height:96px/);
});

test("assistant code blocks wrap long lines and expose a dedicated copy action", async () => {
  const [markdown, codeBlock, css] = await Promise.all([
    readFile(new URL("./AssistantMarkdown.jsx", import.meta.url), "utf8"),
    readFile(new URL("./CodeBlock.jsx", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8")
  ]);

  assert.match(markdown, /import \{ CodeBlock \} from "\.\/CodeBlock\.jsx"/);
  assert.match(markdown, /return text\.trim\(\) \? <CodeBlock \{\.\.\.props\} text=\{text\}>\{children\}<\/CodeBlock> : null/);
  assert.match(codeBlock, /import \{ MessageCopyButton \} from "\.\/MessageCopyButton\.jsx"/);
  assert.match(codeBlock, /className="markdown-code-block"/);
  assert.match(codeBlock, /className="markdown-code-copy-action"/);
  assert.match(codeBlock, /<MessageCopyButton text=\{text\} active onDismiss=\{noop\} standalone \/>/);
  assert.doesNotMatch(codeBlock, /conversation\.copy|markdown-code-copy-button/);
  assert.match(css, /\.message\.assistant\.markdown-body \.markdown-code-block\{[^}]*position:relative[^}]*max-width:100%/);
  assert.match(css, /\.message\.assistant\.markdown-body pre\{[^}]*overflow-x:hidden/);
  assert.match(css, /\.message\.assistant\.markdown-body pre code\{[^}]*white-space:pre-wrap[^}]*overflow-wrap:anywhere/);
  assert.match(css, /\.markdown-code-copy-action\{[^}]*position:absolute[^}]*top:8px[^}]*right:8px/);
});

test("assistant replies render backend-issued artifacts and sandboxed HTML previews", async () => {
  const [markdown, preview, css] = await Promise.all([
    readFile(new URL("./AssistantMarkdown.jsx", import.meta.url), "utf8"),
    readFile(new URL("./ArtifactPreview.jsx", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8")
  ]);

  assert.match(markdown, /assistantSegments\(children\)/);
  assert.match(markdown, /artifactInfo\(href\)/);
  assert.match(markdown, /<ArtifactPreview/);
  assert.match(markdown, /className="artifact-link"/);
  assert.match(markdown, /download=\{artifact\.mimeType === "application\/zip" \? artifact\.name : undefined\}/);
  assert.match(preview, /sandbox=""/);
  assert.doesNotMatch(preview, /allow-scripts/);
  assert.match(preview, /referrerPolicy="no-referrer"/);
  assert.match(preview, /role="dialog"/);
  assert.match(preview, /useBackLayer\(open/);
  assert.doesNotMatch(preview, /dangerouslySetInnerHTML/);
  assert.match(css, /\.artifact-preview-frame\{[^}]*width:100%[^}]*border:0/);
  assert.match(css, /\.artifact-preview-overlay\{[^}]*position:fixed[^}]*inset:0/);
  assert.match(css, /@media\(max-width:640px\).*\.artifact-preview-frame/s);
  assert.match(css, /html\[data-theme="light"\] \.app-shell :is\(\.artifact-link,\.artifact-preview-card,\.artifact-preview-dialog\)\{[^}]*background:#fff!important[^}]*background-color:#fff!important/);
  assert.match(css, /html\[data-theme="light"\] \.app-shell :is\(\.artifact-link-copy small,\.artifact-link-size,\.artifact-preview-card small\)\{[^}]*color:#57606a!important/);
});

test("wide Markdown content scrolls inside the assistant message without widening the conversation", async () => {
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(css, /\.conversation-turn\{[^}]*min-width:0/);
  assert.match(css, /\.message\.assistant\.markdown-body\{[^}]*width:min\(82%,720px\)[^}]*min-width:0/);
  assert.match(css, /@media\(max-width:640px\)\{[^}]*\.message\.assistant\.markdown-body\{[^}]*width:88%/);
});

test("assistant copy actions stay visible while user copy actions open on demand", async () => {
  const [turn, css] = await Promise.all([
    readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8")
  ]);

  assert.match(turn, /MessageCopyButton/);
  assert.match(turn, /activeMessageId/);
  assert.match(turn, /onKeyDown=\{\(event\) => handleMessageKeyDown/);
  assert.match(turn, /event\.key === "Escape"/);
  assert.match(turn, /pointerdown/);
  assert.match(turn, /side="assistant" alwaysShowActions/);
  assert.match(turn, /const actionsVisible = alwaysShowActions \|\| open/);
  assert.match(turn, /aria-expanded=\{actionsVisible\}/);
  assert.match(turn, /className=\{`message-copy-popover \$\{actionsVisible \? "is-open" : ""\}`\}/);
  assert.match(turn, /aria-hidden=\{!actionsVisible\}/);
  assert.match(turn, /<MessageCopyButton text=\{text\} active=\{actionsVisible\}/);
  assert.match(css, /\.message-copy-popover\{[^}]*position:static[^}]*border:0[^}]*background:transparent[^}]*box-shadow:none/);
  assert.match(css, /\.message-copy-popover\{[^}]*max-height:0[^}]*opacity:0[^}]*transform:translateY\(-8px\)[^}]*transition:/);
  assert.match(css, /\.message-copy-popover\.is-open\{[^}]*max-height:48px[^}]*opacity:1[^}]*transform:translateY\(0\)/);
  assert.match(css, /\.message-copy-button\{[^}]*width:48px[^}]*min-height:48px[^}]*background:transparent/);
  assert.match(css, /\.message-with-actions\{[^}]*display:grid/);
  assert.match(css, /\.message-with-actions \.message\.assistant\.markdown-body\{[^}]*width:100%/);
});

test("successful message copies show quiet localized inline feedback before closing", async () => {
  const [button, css, i18n] = await Promise.all([
    readFile(new URL("./MessageCopyButton.jsx", import.meta.url), "utf8"),
    readFile(new URL("./conversation.css", import.meta.url), "utf8"),
    readFile(new URL("../../shared/i18n.js", import.meta.url), "utf8")
  ]);

  assert.match(button, /const \[copied, setCopied\] = useState\(false\)/);
  assert.match(button, /if \(!await copyMessage\(text\)\) \{/);
  assert.match(button, /setCopied\(true\)/);
  assert.match(button, /t\("conversation\.copied"\)/);
  assert.match(button, /<CheckIcon \/>/);
  assert.match(button, /dismissTimer\.current = setTimeout\(\(\) => \{\s*setCopied\(false\);\s*onDismiss\(\);\s*\}, 1200\)/);
  assert.match(button, /clearTimeout\(dismissTimer\.current\)/);
  assert.match(css, /\.message-copy-button\.is-copied\{[^}]*width:auto/);
  assert.match(css, /\.message-copy-label\{[^}]*font-size:12px/);
  assert.match(i18n, /"conversation\.copied": "Copied"/);
  assert.match(i18n, /"conversation\.copied": "복사됨"/);
});

test("message action wrappers shrink to their content without exceeding the mobile width", async () => {
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(css, /\.message-with-actions\{[^}]*width:fit-content[^}]*max-width:min\(82%,720px\)/);
  assert.match(css, /@media\(max-width:640px\)\{\.message-with-actions\{[^}]*max-width:88%/);
  assert.doesNotMatch(css, /@media\(max-width:640px\)\{\.message-with-actions\{width:88%/);
});

test("interrupted turns show the stopped activity card even before logs arrive", async () => {
  const turn = await readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8");

  assert.match(turn, /const isStopped = isLast && turn\.status === "interrupted"/);
  assert.match(turn, /showActivity: sectionLogCount > 0 \|\| \(\(isActive \|\| isStopped\) && totalLogCount === 0\)/);
});

test("active turns show immediate compact work feedback before logs arrive", async () => {
  const turn = await readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8");
  const card = await readFile(new URL("./ActivityCard.jsx", import.meta.url), "utf8");

  assert.match(turn, /const isActive = isLast && \["thinking", "running", "waiting_approval", "waiting_response"\]\.includes\(turn\.status\)/);
  assert.match(turn, /showActivity: sectionLogCount > 0 \|\| \(\(isActive \|\| isStopped\) && totalLogCount === 0\)/);
  assert.match(card, /const hasDetails = logCount > 0/);
  assert.match(card, /const activityLabel = activityTitle\(turn, logCount, t\)/);
  assert.match(card, /\{hasDetails && <div className="activity-details">/);
});

test.skip("sending state renders under the user message instead of as a work log", async () => {
  const turn = await readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(turn, /<UserMessage message=\{section\.message\}[^>]*messageId=/);
  assert.match(turn, /message\.status === "sending"/);
  assert.match(turn, /className="message-send-status"/);
  assert.match(turn, />전송중<\/span>/);
  assert.match(turn, /const isActive = isLast && \["thinking", "running", "waiting_approval", "waiting_response"\]\.includes\(turn\.status\)/);
  assert.doesNotMatch(turn, /\["sending", "thinking", "running"/);
  assert.match(css, /\.message-send-status\{/);
});

test("steering split activity only leaves the last section actively timing", async () => {
  const turn = await readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8");

  assert.match(turn, /status: isLast \? turn\.status : "completed"/);
  assert.match(turn, /completedAt: isLast \? turn\.completedAt : nextMessage\?\.createdAt/);
  assert.match(turn, /durationMs: isLast \? turn\.activity\.durationMs : durationBetween\(turn\.startedAt, nextMessage\?\.createdAt\)/);
});

test("running activity cards keep status emphasis without inline shimmer dots", async () => {
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.doesNotMatch(css, /\.activity-card\.running \.activity-log-label::after/);
  assert.doesNotMatch(css, /activity-thinking-dots/);
  assert.match(css, /\.activity-card\.running \.activity-state i\{[^}]*box-shadow:/);
  assert.match(css, /@media\(prefers-reduced-motion:reduce\)/);
});

test("active conversations show a bottom live-work wave with balanced vertical spacing", async () => {
  const panel = await readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8");
  const css = await readFile(new URL("./conversation.css", import.meta.url), "utf8");

  assert.match(panel, /function ConversationLiveIndicator/);
  assert.match(panel, /className="conversation-live-indicator"/);
  assert.match(panel, /className="conversation-live-wave"/);
  const liveIndicator = '{!loading && activeTurnInProgress && <ConversationLiveIndicator label={session?.status === "starting" ? t("activity.started") : t("activity.running")} />}';
  assert.ok(panel.includes(liveIndicator));
  assert.ok(panel.indexOf(liveIndicator) > panel.indexOf("conversation.map"));
  assert.match(panel, /\[0, 1, 2\]\.map/);
  assert.match(css, /\.conversation-live-indicator\{[^}]*justify-content:flex-start[^}]*margin:-8px 0 30px[^}]*padding-left:14px/);
  assert.match(css, /\.conversation-live-wave\{[^}]*background:transparent[^}]*box-shadow:none/);
  assert.match(css, /\.conversation-live-wave span\{[^}]*conversation-live-wave/);
  assert.match(css, /@keyframes conversation-live-wave/);
  assert.match(css, /@media\(prefers-reduced-motion:reduce\)[\s\S]*\.conversation-live-wave span/);
});

test("approval cards disable duplicate decisions while a decision is pending", async () => {
  const [panel, turn, card] = await Promise.all([
    readFile(new URL("./ConversationPanel.jsx", import.meta.url), "utf8"),
    readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8"),
    readFile(new URL("./ApprovalCard.jsx", import.meta.url), "utf8")
  ]);

  assert.match(panel, /approvalDecisionPending=\{approvalDecisionPending\}/);
  assert.match(turn, /approvalDecisionPending=\{approvalDecisionPending\}/);
  assert.match(turn, /decisionPending=\{approvalDecisionPending === a\.id\}/);
  assert.match(card, /decisionPending/);
  assert.match(card, /disabled=\{decisionPending\}/);
  assert.match(card, /const display = approvalDisplay\(approval, t\)/);
  assert.match(card, /decisionPending \? t\("approval\.resolving"\) : display\.status/);
});

test("approval cards expose one-time, session, and proposed permanent scopes", async () => {
  const card = await readFile(new URL("./ApprovalCard.jsx", import.meta.url), "utf8");

  assert.match(card, /onDecision\(approval, "approved"\)/);
  assert.match(card, /onDecision\(approval, "approved_for_session"\)/);
  assert.match(card, /approval\.canApproveAlways/);
  assert.match(card, /onDecision\(approval, "approved_always"\)/);
  assert.match(card, /t\("approval\.approveOnce"\)/);
  assert.match(card, /t\("approval\.approveSession"\)/);
  assert.match(card, /t\("approval\.approveAlways"\)/);
});

test("sending status uses the active locale key", async () => {
  const turn = await readFile(new URL("./ConversationTurn.jsx", import.meta.url), "utf8");
  assert.match(turn, /message\.status === "sending"/);
  assert.match(turn, /className="message-send-status"\>\{t\("conversation\.sending"\)\}/);
});
