feat: calm composer controls (#88772)

This commit is contained in:
Peter Steinberger
2026-05-31 23:37:27 +01:00
committed by GitHub
parent 56b8030cd9
commit 2b30951b80
60 changed files with 2681 additions and 2185 deletions

View File

@@ -7,6 +7,10 @@ import {
createControlUiMockGatewayInitScript,
type ControlUiMockGatewayScenario,
} from "../ui/src/test-helpers/control-ui-e2e.ts";
import {
resolveSourcePackageAliasesForVite,
resolveTsconfigPathAliasesForVite,
} from "../ui/vite.config.ts";
type CliOptions = {
host: string;
@@ -190,6 +194,64 @@ function searchPrefixes(term: string): string[] {
function createChatPickerScenario(): ControlUiMockGatewayScenario {
const baseTime = Date.parse("2026-05-22T09:00:00.000Z");
const workspaceFiles = [
{
missing: false,
name: "AGENTS.md",
path: "/mock/workspace/AGENTS.md",
size: 2148,
updatedAtMs: baseTime - 120_000,
},
{
missing: false,
name: "plan.md",
path: "/mock/workspace/plan.md",
size: 912,
updatedAtMs: baseTime - 90_000,
},
{
missing: false,
name: "notes/context.md",
path: "/mock/workspace/notes/context.md",
size: 1620,
updatedAtMs: baseTime - 30_000,
},
];
const workspaceListCases = ["main", "alpha", "openclaw-mock"].map((agentId) => ({
match: { agentId },
response: {
agentId,
files: workspaceFiles,
workspace: "/mock/workspace",
},
}));
const workspaceFileContentByName = new Map([
[
"AGENTS.md",
"# AGENTS.md\n\nMock workspace instructions for the composer rail.\n\n- Keep tool output compact.\n- Prefer right-rail context over modal previews.\n",
],
[
"plan.md",
"# Plan\n\n- Simplify composer controls.\n- Keep session switching available in collapsed navigation.\n- Verify the workspace rail can open every listed mock file.\n",
],
[
"notes/context.md",
"# Context\n\nThe mock workspace keeps enough files to exercise right-rail selection, preview loading, and stale request guards without a real gateway.\n",
],
]);
const workspaceFileCases = ["main", "alpha", "openclaw-mock"].flatMap((agentId) =>
workspaceFiles.map((file) => ({
match: { agentId, name: file.name },
response: {
agentId,
file: {
...file,
content: workspaceFileContentByName.get(file.name) ?? "",
},
workspace: "/mock/workspace",
},
})),
);
const sessions = [
sessionRow("agent:alpha", "Alpha planning", baseTime - 1_000),
...buildSessionRows({
@@ -219,6 +281,12 @@ function createChatPickerScenario(): ControlUiMockGatewayScenario {
defaultAgentId: "openclaw-mock",
historyMessages: buildScrollableChatHistory(baseTime),
methodResponses: {
"agents.files.get": {
cases: workspaceFileCases,
},
"agents.files.list": {
cases: workspaceListCases,
},
"sessions.list": {
cases: [
...buildSearchSessionListCases(telegramSessions, searchPrefixes("telegram")),
@@ -301,6 +369,9 @@ const server = await createServer({
},
plugins: [createMockGatewayPlugin(scenario)],
publicDir: path.join(uiRoot, "public"),
resolve: {
alias: [...resolveSourcePackageAliasesForVite(), ...resolveTsconfigPathAliasesForVite()],
},
root: uiRoot,
server: {
host: options.host,

View File

@@ -67,7 +67,6 @@ function createHost(agentsPanel: AgentsPanel): Parameters<typeof refreshActiveTa
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:58:19.006Z",
"generatedAt": "2026-05-31T22:16:42.198Z",
"locale": "ar",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:57:00.109Z",
"generatedAt": "2026-05-31T22:16:41.346Z",
"locale": "de",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:57:38.784Z",
"generatedAt": "2026-05-31T22:16:41.510Z",
"locale": "es",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:59:46.957Z",
"generatedAt": "2026-05-31T22:16:43.582Z",
"locale": "fa",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:57:49.001Z",
"generatedAt": "2026-05-31T22:16:42.023Z",
"locale": "fr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:59:05.531Z",
"generatedAt": "2026-05-31T22:16:42.828Z",
"locale": "id",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:58:21.052Z",
"generatedAt": "2026-05-31T22:16:42.359Z",
"locale": "it",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:57:36.996Z",
"generatedAt": "2026-05-31T22:16:41.686Z",
"locale": "ja-JP",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:57:44.078Z",
"generatedAt": "2026-05-31T22:16:41.862Z",
"locale": "ko",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:59:54.345Z",
"generatedAt": "2026-05-31T22:16:43.439Z",
"locale": "nl",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:59:11.329Z",
"generatedAt": "2026-05-31T22:16:42.976Z",
"locale": "pl",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:57:03.893Z",
"generatedAt": "2026-05-31T22:16:41.182Z",
"locale": "pt-BR",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,34 +1,6 @@
{
"version": 1,
"entries": [
{
"count": 1,
"kind": "html-attribute",
"name": "aria-label",
"path": "ui/src/ui/app-render.ts",
"text": "Preparing chat handoff"
},
{
"count": 1,
"kind": "html-attribute",
"name": "aria-label",
"path": "ui/src/ui/app-render.ts",
"text": "Workshop view"
},
{
"count": 1,
"kind": "html-attribute",
"name": "title",
"path": "ui/src/ui/app-render.ts",
"text": "Board view"
},
{
"count": 1,
"kind": "html-attribute",
"name": "title",
"path": "ui/src/ui/app-render.ts",
"text": "Today view"
},
{
"count": 1,
"kind": "html-text",
@@ -36,13 +8,6 @@
"path": "ui/src/ui/app-render.ts",
"text": "⌘K"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/app-render.ts",
"text": "Board"
},
{
"count": 1,
"kind": "html-text",
@@ -50,20 +15,6 @@
"path": "ui/src/ui/app-render.ts",
"text": "OpenClaw"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/app-render.ts",
"text": "Today"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/app-render.ts",
"text": "Use current chat"
},
{
"count": 1,
"kind": "object-property",
@@ -251,13 +202,20 @@
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/chat/grouped-render.ts",
"text": "Context"
"text": "Activity"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/chat/grouped-render.ts",
"text": "Context"
},
{
"count": 2,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/chat/grouped-render.ts",
"text": "Error"
},
{
@@ -295,6 +253,48 @@
"path": "ui/src/ui/chat/grouped-render.ts",
"text": "Unknown date"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/chat/session-controls.ts",
"text": "Model"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/chat/session-controls.ts",
"text": "Reasoning"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/chat/session-controls.ts",
"text": "Speed"
},
{
"count": 1,
"kind": "object-property",
"name": "label",
"path": "ui/src/ui/chat/session-controls.ts",
"text": "Default"
},
{
"count": 1,
"kind": "object-property",
"name": "label",
"path": "ui/src/ui/chat/session-controls.ts",
"text": "Fast"
},
{
"count": 1,
"kind": "object-property",
"name": "label",
"path": "ui/src/ui/chat/session-controls.ts",
"text": "Standard"
},
{
"count": 1,
"kind": "html-attribute",
@@ -1483,14 +1483,14 @@
"kind": "html-attribute",
"name": "aria-label",
"path": "ui/src/ui/views/chat.ts",
"text": "Exit focus mode"
"text": "Loading chat"
},
{
"count": 1,
"kind": "html-attribute",
"name": "aria-label",
"path": "ui/src/ui/views/chat.ts",
"text": "Loading chat"
"text": "Refresh files"
},
{
"count": 1,
@@ -1514,12 +1514,26 @@
"text": "Slash commands"
},
{
"count": 2,
"count": 1,
"kind": "html-attribute",
"name": "aria-label",
"path": "ui/src/ui/views/chat.ts",
"text": "Talk options"
},
{
"count": 1,
"kind": "html-attribute",
"name": "aria-label",
"path": "ui/src/ui/views/chat.ts",
"text": "Talk settings"
},
{
"count": 1,
"kind": "html-attribute",
"name": "aria-label",
"path": "ui/src/ui/views/chat.ts",
"text": "Workspace files"
},
{
"count": 1,
"kind": "html-attribute",
@@ -1546,14 +1560,14 @@
"kind": "html-attribute",
"name": "title",
"path": "ui/src/ui/views/chat.ts",
"text": "Exit focus mode"
"text": "Refresh files"
},
{
"count": 1,
"kind": "html-attribute",
"name": "title",
"path": "ui/src/ui/views/chat.ts",
"text": "Talk options"
"text": "Talk settings"
},
{
"count": 1,
@@ -1625,6 +1639,13 @@
"path": "ui/src/ui/views/chat.ts",
"text": "Exact VAD"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/views/chat.ts",
"text": "Files"
},
{
"count": 2,
"kind": "html-text",
@@ -1667,6 +1688,13 @@
"path": "ui/src/ui/views/chat.ts",
"text": "Lead-in"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/views/chat.ts",
"text": "Loading files..."
},
{
"count": 2,
"kind": "html-text",
@@ -1688,6 +1716,13 @@
"path": "ui/src/ui/views/chat.ts",
"text": "Minimal"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/views/chat.ts",
"text": "Missing"
},
{
"count": 1,
"kind": "html-text",
@@ -1709,6 +1744,13 @@
"path": "ui/src/ui/views/chat.ts",
"text": "No matching messages"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/views/chat.ts",
"text": "No workspace files"
},
{
"count": 1,
"kind": "html-text",
@@ -1772,6 +1814,13 @@
"path": "ui/src/ui/views/chat.ts",
"text": "Tab"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/views/chat.ts",
"text": "Talk settings"
},
{
"count": 1,
"kind": "html-text",
@@ -1793,6 +1842,13 @@
"path": "ui/src/ui/views/chat.ts",
"text": "WebRTC"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/ui/views/chat.ts",
"text": "Workspace"
},
{
"count": 1,
"kind": "html-text",

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:59:05.808Z",
"generatedAt": "2026-05-31T22:16:43.124Z",
"locale": "th",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:58:31.641Z",
"generatedAt": "2026-05-31T22:16:42.527Z",
"locale": "tr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:58:29.307Z",
"generatedAt": "2026-05-31T22:16:42.683Z",
"locale": "uk",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:59:15.154Z",
"generatedAt": "2026-05-31T22:16:43.287Z",
"locale": "vi",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:56:56.636Z",
"generatedAt": "2026-05-31T22:16:40.845Z",
"locale": "zh-CN",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -1,9 +1,9 @@
{
"fallbackKeys": [],
"generatedAt": "2026-05-31T21:57:02.528Z",
"generatedAt": "2026-05-31T22:16:41.021Z",
"locale": "zh-TW",
"model": "claude-opus-4-8",
"provider": "anthropic",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "4f4fe84b520c2fb80a3c0b83d47dff447f14331de9c8348c53043fa7877abdbb",
"totalKeys": 1295,
"translatedKeys": 1295,

View File

@@ -12,6 +12,7 @@
width: 100%;
min-height: 0;
/* Allow flex shrinking */
padding: 0 !important;
overflow: hidden;
background: transparent !important;
border: none !important;
@@ -67,47 +68,6 @@
margin-top: 0 !important;
}
/* Focus mode exit button */
.chat-focus-exit {
position: absolute;
top: 12px;
right: 12px;
z-index: 100;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--panel);
color: var(--muted);
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
background 150ms ease-out,
color 150ms ease-out,
border-color 150ms ease-out;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.chat-focus-exit:hover {
background: var(--panel-strong);
color: var(--text);
border-color: var(--accent);
}
.chat-focus-exit svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
}
/* New messages indicator - floating pill above compose */
.chat-new-messages {
align-self: center;
@@ -664,6 +624,219 @@
color: var(--muted);
}
.agent-chat__composer-status-stack {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 8px 10px 0;
}
.agent-chat__composer-status-stack:empty {
display: none;
}
.agent-chat__composer-status-stack .context-notice,
.agent-chat__composer-status-stack .compaction-indicator,
.agent-chat__composer-status-stack .fallback-indicator,
.agent-chat__composer-status-stack .agent-chat__goal {
margin: 0;
}
.agent-chat__composer-controls {
display: flex;
position: relative;
align-items: center;
justify-content: flex-start;
gap: 6px;
flex: 1 1 auto;
min-width: 0;
padding: 0;
}
.agent-chat__composer-session {
min-width: 0;
flex: 1 1 260px;
}
.agent-chat__composer-controls .chat-controls {
display: flex;
justify-content: flex-end;
gap: 6px;
min-width: 0;
margin: 0;
}
.agent-chat__composer-controls .chat-controls__session-row {
min-width: 0;
}
.agent-chat__composer-controls .chat-controls__actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.chat-settings-popover-wrapper {
position: relative;
display: inline-flex;
align-items: center;
margin-left: auto;
min-width: 0;
}
.chat-composer-model-control {
display: inline-flex;
flex: 0 1 220px;
min-width: 0;
}
.chat-composer-model-control .chat-controls__model {
width: 100%;
}
.chat-settings-chip {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
min-width: 88px;
max-width: min(34vw, 150px);
height: 36px;
padding: 0 10px;
border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--bg-elevated) 78%, transparent);
color: var(--muted);
font: inherit;
font-size: 13px;
cursor: pointer;
transition:
background var(--duration-fast) ease,
border-color var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.chat-settings-chip:hover,
.chat-settings-chip--open {
border-color: color-mix(in srgb, var(--border-strong) 82%, var(--border));
background: color-mix(in srgb, var(--panel) 82%, var(--bg-elevated));
color: var(--text);
}
.chat-settings-chip__icon,
.chat-settings-chip__chevron {
display: inline-flex;
flex: 0 0 auto;
}
.chat-settings-chip svg {
width: 15px;
height: 15px;
stroke: currentColor;
fill: none;
stroke-width: 1.7px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-settings-chip__chevron svg {
width: 13px;
height: 13px;
opacity: 0.75;
}
.chat-settings-chip__text {
flex: 0 0 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-settings-popover {
position: absolute;
right: 0;
bottom: calc(100% + 10px);
z-index: 60;
display: none;
width: min(320px, calc(100vw - 42px));
padding: 12px;
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--bg-elevated) 94%, var(--card));
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
}
.chat-settings-popover--open {
display: grid;
gap: 12px;
}
.chat-settings-popover__section {
display: grid;
gap: 8px;
}
.chat-settings-popover__label {
color: var(--muted);
font-size: 11px;
font-weight: 650;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.chat-settings-popover__toggles {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.chat-settings-popover__toggles .btn--icon.chat-settings-action {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
width: 100%;
min-width: 0;
height: 38px;
padding: 0 10px;
border-radius: var(--radius-md);
border: 1px solid color-mix(in srgb, var(--border) 74%, transparent);
background: color-mix(in srgb, var(--card) 74%, transparent);
color: var(--muted);
}
.chat-settings-popover__toggles .btn--icon.chat-settings-action:hover:not(:disabled),
.chat-settings-popover__toggles .btn.btn--icon.chat-settings-action.active {
border-color: color-mix(in srgb, var(--text) 22%, var(--border));
background: color-mix(in srgb, var(--text) 6%, var(--card));
color: color-mix(in srgb, var(--text) 82%, var(--muted));
}
.chat-settings-popover__toggles .btn.btn--icon.chat-settings-action.active svg {
stroke: currentColor;
}
:root[data-theme-mode="light"]
.chat-settings-popover__toggles
.btn.btn--icon.chat-settings-action.active {
border-color: rgba(16, 24, 40, 0.18);
background: rgba(16, 24, 40, 0.045);
color: #475467;
box-shadow: none;
}
.chat-settings-action__text {
min-width: 0;
overflow: hidden;
color: currentColor;
font-size: 12px;
font-weight: 600;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-chat__stt-interim {
padding: 10px 14px 0;
color: var(--muted);
@@ -688,6 +861,11 @@
gap: 4px;
}
.agent-chat__toolbar-left {
flex: 1 1 auto;
min-width: 0;
}
.agent-chat__input-btn,
.agent-chat__toolbar .btn--ghost {
display: inline-flex;
@@ -718,13 +896,7 @@
}
.agent-chat__control-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.76rem;
font-weight: 500;
line-height: 1;
display: none;
}
.agent-chat__input-btn:hover:not(:disabled),
@@ -1386,10 +1558,8 @@
.chat-controls__session-row {
display: grid;
grid-template-columns:
minmax(116px, 5fr) minmax(132px, 7fr) minmax(132px, 5fr)
minmax(128px, 4fr);
grid-template-areas: "agent session model thinking";
grid-template-columns: minmax(116px, 5fr) minmax(132px, 7fr) minmax(180px, 8fr);
grid-template-areas: "agent session model";
align-items: center;
gap: 6px;
width: 100%;
@@ -1398,23 +1568,37 @@
}
.chat-controls__session-row--single-agent {
grid-template-columns: minmax(132px, 7fr) minmax(132px, 5fr) minmax(128px, 4fr);
grid-template-areas: "session model thinking";
grid-template-columns: minmax(132px, 7fr) minmax(180px, 8fr);
grid-template-areas: "session model";
}
.chat-controls__session-row--has-quota {
grid-template-columns:
minmax(116px, 5fr) minmax(132px, 7fr) minmax(132px, 5fr)
minmax(128px, 4fr) minmax(104px, auto);
grid-template-areas: "agent session model thinking quota";
minmax(116px, 5fr) minmax(132px, 7fr) minmax(180px, 8fr)
minmax(104px, auto);
grid-template-areas: "agent session model quota";
}
.chat-controls__session-row--single-agent.chat-controls__session-row--has-quota {
grid-template-columns: minmax(132px, 7fr) minmax(132px, 5fr) minmax(128px, 4fr) minmax(
104px,
auto
);
grid-template-areas: "session model thinking quota";
grid-template-columns: minmax(132px, 7fr) minmax(180px, 8fr) minmax(104px, auto);
grid-template-areas: "session model quota";
}
.chat-controls__session-row--session-switcher {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"agent"
"session";
gap: 6px;
}
.chat-controls__session-row--session-switcher.chat-controls__session-row--single-agent {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "session";
}
.chat-controls__session-row--compact {
width: 44px;
}
.chat-controls__session-picker {
@@ -1470,6 +1654,14 @@
color: var(--muted);
}
.chat-controls__session-trigger-compact-icon {
display: inline-flex;
width: 18px;
height: 18px;
flex: 0 0 auto;
color: var(--muted);
}
.chat-controls__session-trigger-icon svg {
width: 16px;
height: 16px;
@@ -1478,6 +1670,14 @@
stroke-width: 1.5px;
}
.chat-controls__session-trigger-compact-icon svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
}
.chat-session-picker {
position: absolute;
top: calc(100% + 8px);
@@ -1670,10 +1870,245 @@
max-width: none;
}
.chat-controls__thinking-select {
grid-area: thinking;
.chat-controls__inline-select {
position: relative;
min-width: 0;
max-width: none;
}
.chat-controls__inline-select summary {
list-style: none;
}
.chat-controls__inline-select summary::-webkit-details-marker {
display: none;
}
.chat-controls__inline-select-trigger {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
height: 36px;
min-height: 36px;
padding: 0 10px 0 12px;
border: 1px solid color-mix(in srgb, var(--input) 88%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--card) 92%, var(--bg-elevated) 8%);
color: var(--text);
font-size: 13px;
line-height: 1;
cursor: pointer;
user-select: none;
}
.chat-controls__inline-select-trigger:hover {
border-color: color-mix(in srgb, var(--text) 18%, var(--input));
background: color-mix(in srgb, var(--card) 82%, var(--bg-elevated) 18%);
}
.chat-controls__inline-select-trigger:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.chat-controls__inline-select-trigger--disabled {
cursor: not-allowed;
opacity: 0.55;
}
.chat-controls__inline-select-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-controls__inline-select-icon {
display: inline-flex;
width: 14px;
height: 14px;
color: var(--muted);
flex: 0 0 auto;
}
.chat-controls__inline-select-icon svg,
.chat-controls__inline-select-check svg {
width: 100%;
height: 100%;
stroke: currentColor;
fill: none;
}
.chat-controls__inline-select[open] .chat-controls__inline-select-icon {
transform: rotate(180deg);
}
.chat-controls__inline-select-menu {
position: absolute;
left: 0;
bottom: calc(100% + 6px);
z-index: 80;
display: grid;
gap: 2px;
width: max(100%, min(260px, calc(100vw - 32px)));
max-height: min(280px, calc(100vh - 120px));
padding: 6px;
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--bg-elevated) 96%, var(--card));
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.3);
overflow-y: auto;
}
.chat-controls__inline-select-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
min-height: 34px;
padding: 7px 9px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.chat-controls__inline-select-option:hover,
.chat-controls__inline-select-option:focus-visible {
border-color: color-mix(in srgb, var(--border) 76%, transparent);
background: var(--bg-hover);
outline: none;
}
.chat-controls__inline-select-option--selected {
color: var(--text-strong);
background: color-mix(in srgb, var(--text) 6%, transparent);
}
.chat-controls__inline-select-menu--combined {
width: max(100%, min(320px, calc(100vw - 32px)));
max-height: none;
overflow: visible;
}
.chat-controls__combined-model-list {
display: grid;
gap: 2px;
max-height: min(376px, calc(100vh - 164px));
overflow-y: auto;
padding-right: 2px;
}
.chat-controls__inline-select-section-label {
padding: 4px 8px 3px;
color: var(--muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.chat-controls__combined-model {
position: relative;
display: block;
}
.chat-controls__combined-model-arrow svg {
transform: rotate(-90deg);
}
.chat-controls__reasoning-panel {
position: absolute;
bottom: 0;
left: calc(100% + 8px);
z-index: 90;
display: grid;
gap: 6px;
width: 180px;
max-height: min(390px, calc(100vh - 180px));
padding: 8px;
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--bg-elevated) 96%, var(--card));
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
overflow-y: auto;
opacity: 0;
pointer-events: none;
transform: translateX(-4px);
transition:
opacity var(--duration-fast) ease,
transform var(--duration-fast) ease;
}
.chat-controls__inline-select[open] .chat-controls__reasoning-panel,
.chat-controls__inline-select-menu--combined:hover > .chat-controls__reasoning-panel,
.chat-controls__inline-select-menu--combined:focus-within > .chat-controls__reasoning-panel {
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
.chat-controls__reasoning-options {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 4px;
}
.chat-controls__reasoning-option {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 5px;
min-width: 0;
min-height: 32px;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
font: inherit;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.chat-controls__reasoning-option:hover:not(:disabled),
.chat-controls__reasoning-option:focus-visible {
border-color: color-mix(in srgb, var(--text) 20%, var(--border));
background: color-mix(in srgb, var(--text) 6%, var(--card));
color: var(--text);
outline: none;
}
.chat-controls__reasoning-option--selected {
border-color: color-mix(in srgb, var(--text) 24%, var(--border));
background: color-mix(in srgb, var(--text) 8%, var(--card));
color: var(--text-strong);
}
.chat-controls__reasoning-option:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.chat-controls__reasoning-option .chat-controls__inline-select-check {
width: 12px;
height: 12px;
}
.chat-controls__inline-select-check {
display: inline-flex;
width: 14px;
height: 14px;
color: var(--accent);
flex: 0 0 auto;
}
.chat-controls__quota {
@@ -1753,9 +2188,7 @@
}
.chat-controls__session select,
.chat-controls__agent select,
.chat-controls__model select,
.chat-controls__thinking-select select {
.chat-controls__agent select {
box-sizing: border-box;
height: 36px;
min-height: 36px;
@@ -1791,13 +2224,6 @@
border-color: rgba(16, 24, 40, 0.15);
}
@media (max-width: 1535px) {
.chat-controls__thinking-select {
min-width: 0;
max-width: none;
}
}
@media (max-width: 768px) {
.chat-controls__session {
min-width: 120px;
@@ -1854,6 +2280,36 @@
min-width: 140px;
}
.agent-chat__composer-controls {
align-items: center;
flex-direction: row;
}
.agent-chat__composer-controls .chat-controls {
justify-content: flex-start;
}
.chat-settings-chip {
max-width: 48vw;
}
.chat-settings-popover {
position: fixed;
left: max(12px, var(--safe-area-left));
right: max(12px, var(--safe-area-right));
bottom: calc(96px + var(--safe-area-bottom));
width: auto;
max-height: min(480px, calc(100vh - var(--shell-topbar-height) - 116px));
overflow: visible;
}
.chat-controls__reasoning-panel {
top: auto;
bottom: calc(100% + 8px);
left: 0;
width: min(220px, calc(100vw - 48px));
}
.chat-compose {
grid-template-columns: 1fr;
}

View File

@@ -1,10 +1,20 @@
/* Split View Layout */
.chat-workbench {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(230px, 280px);
gap: 0;
flex: 1;
min-height: 0;
overflow: hidden;
}
.chat-split-container {
display: flex;
gap: 0;
flex: 1;
min-height: 0;
height: 100%;
min-width: 0;
}
.chat-main {
@@ -26,6 +36,153 @@
animation: slide-in 200ms ease-out;
}
.chat-workspace-rail {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border-left: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 84%, transparent);
}
.chat-workspace-rail__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 12px 10px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 62%, transparent);
}
.chat-workspace-rail__title {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.chat-workspace-rail__eyebrow {
color: var(--muted);
font-size: var(--control-ui-text-xs);
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.chat-workspace-rail__title strong {
color: var(--text);
font-size: var(--control-ui-text-md);
line-height: 1.2;
}
.chat-workspace-rail__refresh {
width: 32px;
min-width: 32px;
height: 32px;
padding: 0;
}
.chat-workspace-rail__refresh svg,
.chat-workspace-rail__file-icon svg {
width: 15px;
height: 15px;
fill: none;
stroke: currentColor;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-workspace-rail__path {
padding: 9px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 42%, transparent);
color: var(--muted);
font-size: var(--control-ui-text-xs);
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-workspace-rail__state {
padding: 14px 12px;
color: var(--muted);
font-size: var(--control-ui-text-sm);
line-height: 1.35;
}
.chat-workspace-rail__state--error {
color: var(--danger, #ef4444);
}
.chat-workspace-rail__list {
display: flex;
flex-direction: column;
gap: 2px;
min-height: 0;
overflow: auto;
padding: 8px;
}
.chat-workspace-rail__file {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 38px;
padding: 7px 8px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
}
.chat-workspace-rail__file:hover,
.chat-workspace-rail__file:focus-visible,
.chat-workspace-rail__file--active {
border-color: color-mix(in srgb, var(--border-strong) 62%, transparent);
background: var(--bg-hover);
}
.chat-workspace-rail__file-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.chat-workspace-rail__file-name,
.chat-workspace-rail__file-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-workspace-rail__file-name {
font-size: var(--control-ui-text-sm);
line-height: 1.2;
}
.chat-workspace-rail__file-meta {
color: var(--muted);
font-size: var(--control-ui-text-xs);
line-height: 1.1;
}
.chat-workspace-rail__file-badge {
padding: 2px 6px;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--danger, #ef4444) 12%, transparent);
color: var(--danger, #ef4444);
font-size: 10px;
line-height: 1.2;
}
@keyframes slide-in {
from {
opacity: 0;
@@ -440,6 +597,16 @@
}
}
@media (max-width: 1120px) {
.chat-workbench {
display: flex;
}
.chat-workspace-rail {
display: none;
}
}
/* Mobile: Full-screen modal */
@media (max-width: 768px) {
.chat-split-container--open {

View File

@@ -928,6 +928,98 @@
animation-delay: 0.4s;
}
.chat-group--activity .chat-group-messages {
max-width: min(760px, 100%);
}
.chat-activity-group {
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--card) 88%, var(--secondary) 12%);
}
.chat-activity-group__summary {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 40px;
padding: 9px 12px;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
}
.chat-activity-group__summary:hover,
.chat-activity-group__summary:focus-visible {
background: color-mix(in srgb, var(--bg-hover) 70%, transparent);
}
.chat-activity-group__summary--error {
color: var(--destructive, var(--danger, #c0392b));
}
.chat-activity-group__icon,
.chat-activity-group__badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.chat-activity-group__icon svg,
.chat-activity-group__badge svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.8px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-activity-group__label {
flex-shrink: 0;
font-size: var(--control-ui-text-sm);
font-weight: 650;
line-height: 1.2;
}
.chat-activity-group__preview {
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: var(--control-ui-text-sm);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-activity-group__badge {
gap: 3px;
margin-left: auto;
padding: 2px 6px;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--destructive, var(--danger, #c0392b)) 15%, transparent);
color: var(--destructive, var(--danger, #c0392b));
font-size: 10px;
font-weight: 650;
letter-spacing: 0.04em;
line-height: 1;
text-transform: uppercase;
}
.chat-activity-group__body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 10px 10px;
}
@keyframes reading-pulse {
0%,
60%,

View File

@@ -4350,13 +4350,6 @@ td.data-table-key-col {
background: linear-gradient(180deg, transparent 0%, var(--bg) 40%);
}
.shell--chat-focus .chat-compose {
bottom: calc(var(--shell-pad) + 8px);
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
border-bottom-left-radius: var(--radius-lg);
border-bottom-right-radius: var(--radius-lg);
}
.chat-compose__field {
gap: 4px;
}

View File

@@ -47,11 +47,6 @@
grid-template-columns: var(--shell-nav-rail-width) minmax(0, 1fr);
}
.shell--chat-focus {
grid-template-columns: 0px minmax(0, 1fr);
grid-template-rows: 0 minmax(0, 1fr);
}
.shell--onboarding {
grid-template-columns: 0 minmax(0, 1fr);
grid-template-rows: 0 1fr;
@@ -69,25 +64,6 @@
padding-top: 0;
}
.shell--chat-focus .content {
padding-top: 0;
}
.shell--chat-focus .content > * + * {
margin-top: 0;
}
.shell--chat-focus .topbar {
min-height: 0;
height: 0;
padding-top: 0;
padding-bottom: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
border-bottom-width: 0;
}
/* ===========================================
Topbar
=========================================== */
@@ -100,7 +76,7 @@
display: flex;
align-items: center;
padding: 0 24px;
min-height: 58px;
min-height: var(--shell-topbar-height);
border-bottom: 1px solid color-mix(in srgb, var(--border) 74%, transparent);
background: color-mix(in srgb, var(--bg) 82%, transparent);
backdrop-filter: blur(12px) saturate(1.6);
@@ -736,6 +712,53 @@
gap: 4px;
}
.sidebar-session-select {
display: grid;
gap: 4px;
min-width: 0;
}
.sidebar-session-select--collapsed {
justify-items: center;
position: relative;
}
.sidebar-session-select .chat-controls__session-row {
min-height: 0;
}
.sidebar-session-select .chat-controls__session-notice {
min-height: 0;
}
.sidebar-session-select .chat-session-picker {
width: 100%;
max-width: 100%;
max-height: min(480px, calc(100vh - 120px));
}
.sidebar-session-select--collapsed .chat-controls__session-trigger {
width: 44px;
min-height: 44px;
justify-content: center;
padding: 0;
border-radius: var(--radius-lg);
}
.sidebar-session-select--collapsed .chat-controls__session-trigger-label,
.sidebar-session-select--collapsed .chat-controls__session-trigger-icon {
display: none;
}
.sidebar-session-select--collapsed .chat-session-picker {
position: absolute;
top: 0;
left: calc(100% + 8px);
width: min(360px, calc(100vw - 96px));
max-width: min(360px, calc(100vw - 96px));
z-index: var(--z-dropdown);
}
.sidebar-recent-session {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
@@ -1009,46 +1032,6 @@
color: var(--accent);
}
.nav-item--child {
position: relative;
margin-left: 18px;
min-height: 32px;
padding: 0 9px 0 18px;
}
.nav-item--child::before {
content: "";
position: absolute;
left: 6px;
top: 0;
bottom: 50%;
width: 8px;
border-left: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-bottom-left-radius: 6px;
pointer-events: none;
}
.nav-item--child .nav-item__icon,
.nav-item--child .nav-item__icon svg {
width: 14px;
height: 14px;
}
.nav-item--child .nav-item__text {
font-size: 13px;
font-weight: 500;
}
.sidebar--collapsed .nav-item--child {
margin-left: 0;
padding-left: 9px;
}
.sidebar--collapsed .nav-item--child::before {
display: none;
}
.sidebar--collapsed .sidebar-shell {
padding: 12px 8px 10px;
}
@@ -1245,15 +1228,11 @@
.shell--nav-collapsed .shell-nav {
width: var(--shell-nav-rail-width);
min-width: var(--shell-nav-rail-width);
overflow: visible;
}
.shell--chat-focus .shell-nav {
width: 0;
min-width: 0;
overflow: hidden;
pointer-events: none;
opacity: 0;
border-right-width: 0;
.shell--nav-collapsed .sidebar {
overflow: visible;
}
.nav-item__external-icon {
@@ -1308,6 +1287,7 @@
flex-direction: column;
gap: 2px;
overflow: hidden;
padding-top: 0;
padding-bottom: 0;
}
@@ -1315,57 +1295,6 @@
margin-top: 0;
}
@media (prefers-reduced-motion: no-preference) {
.content--chat-workshop-handoff .content-header {
animation: workshop-chat-header-enter 340ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.content--chat-workshop-handoff .chat-thread {
animation: workshop-chat-thread-enter 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.content--chat-workshop-handoff .agent-chat__input {
animation: workshop-chat-composer-enter 520ms cubic-bezier(0.16, 1, 0.3, 1) 90ms both;
}
}
@keyframes workshop-chat-header-enter {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes workshop-chat-thread-enter {
from {
opacity: 0;
transform: translateY(12px) scale(0.992);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes workshop-chat-composer-enter {
0% {
opacity: 0;
transform: translateY(18px) scale(0.988);
}
65% {
opacity: 1;
transform: translateY(-2px) scale(1);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.content--workboard {
display: flex;
flex-direction: column;
@@ -1394,14 +1323,6 @@
max-height: 80px;
}
.shell--chat-focus .content-header {
opacity: 0;
transform: translateY(-8px);
max-height: 0px;
padding: 0;
pointer-events: none;
}
.content--chat .content-header.content-header--chat-hidden {
opacity: 0;
transform: translateY(-10px);
@@ -1546,14 +1467,6 @@
min-width: 0;
}
.shell--chat-focus .content--chat .content-header {
min-height: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
overflow: hidden;
}
.content--chat .page-meta {
align-self: center;
justify-content: flex-end;

View File

@@ -32,42 +32,29 @@
row-gap: 0;
}
.shell--chat-focus .content--chat .content-header {
min-height: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
overflow: hidden;
}
.chat-controls__session-row {
grid-template-columns:
minmax(96px, 5fr) minmax(112px, 7fr) minmax(116px, 5fr)
minmax(112px, 4fr);
grid-template-areas: "agent session model thinking";
grid-template-columns: minmax(96px, 5fr) minmax(112px, 7fr) minmax(164px, 8fr);
grid-template-areas: "agent session model";
align-items: center;
gap: 8px;
width: 100%;
}
.chat-controls__session-row--single-agent {
grid-template-columns: minmax(112px, 7fr) minmax(116px, 5fr) minmax(112px, 4fr);
grid-template-areas: "session model thinking";
grid-template-columns: minmax(112px, 7fr) minmax(164px, 8fr);
grid-template-areas: "session model";
}
.chat-controls__session-row--has-quota {
grid-template-columns:
minmax(96px, 5fr) minmax(112px, 7fr) minmax(116px, 5fr)
minmax(112px, 4fr) minmax(96px, auto);
grid-template-areas: "agent session model thinking quota";
minmax(96px, 5fr) minmax(112px, 7fr) minmax(164px, 8fr)
minmax(96px, auto);
grid-template-areas: "agent session model quota";
}
.chat-controls__session-row--single-agent.chat-controls__session-row--has-quota {
grid-template-columns: minmax(112px, 7fr) minmax(116px, 5fr) minmax(112px, 4fr) minmax(
96px,
auto
);
grid-template-areas: "session model thinking quota";
grid-template-columns: minmax(112px, 7fr) minmax(164px, 8fr) minmax(96px, auto);
grid-template-areas: "session model quota";
}
.chat-controls__agent {
@@ -82,10 +69,6 @@
grid-area: model;
}
.chat-controls__thinking-select {
grid-area: thinking;
}
.chat-controls__quota {
grid-area: quota;
min-width: 96px;
@@ -93,16 +76,14 @@
.chat-controls__session,
.chat-controls__agent,
.chat-controls__model,
.chat-controls__thinking-select {
.chat-controls__model {
min-width: 0;
max-width: none;
}
.chat-controls__session select,
.chat-controls__agent select,
.chat-controls__model select,
.chat-controls__thinking-select select {
.chat-controls__inline-select-trigger {
width: 100%;
max-width: none;
}
@@ -119,10 +100,6 @@
"content";
}
.shell--chat-focus {
grid-template-rows: 0 minmax(0, 1fr);
}
.shell-nav,
.shell--nav-collapsed .shell-nav {
position: fixed;
@@ -476,22 +453,22 @@
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
grid-template-areas:
"agent session"
"model thinking";
"model model";
gap: 8px;
width: 100%;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session-row--single-agent {
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"session thinking"
"model model";
"session"
"model";
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session-row--has-quota {
grid-template-areas:
"agent session"
"model thinking"
"model model"
"quota quota";
}
@@ -499,9 +476,9 @@
.chat-controls-dropdown
.chat-controls__session-row--single-agent.chat-controls__session-row--has-quota {
grid-template-areas:
"session thinking"
"model model"
"quota quota";
"session"
"model"
"quota";
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session {
@@ -555,23 +532,24 @@
grid-area: model;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking-select {
grid-area: thinking;
min-width: 0;
max-width: none;
width: 100%;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__quota {
width: 100%;
min-width: 0;
min-height: 40px;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking-select-full {
display: block;
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__inline-select-trigger {
display: flex;
width: 100%;
max-width: none;
min-height: 44px;
font-size: 14px;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__inline-select-menu {
bottom: auto;
top: calc(100% + 6px);
width: 100%;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session select,
@@ -591,7 +569,7 @@
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: center;
justify-content: stretch;
gap: 8px;

View File

@@ -27,11 +27,12 @@ describe("chat header responsive mobile styles", () => {
expect(css).toContain("@media (max-width: 1320px)");
expect(css).toContain(".content--chat .content-header");
expect(layoutCss).toContain(".content--chat {\n display: flex;\n flex-direction: column;\n gap: 2px;\n overflow: hidden;\n padding-top: 0;");
expect(css).toContain("max-height: 44px;");
expect(layoutCss).toContain(".content--chat .content-header .chat-controls__session-notice");
expect(layoutCss).toContain("position: absolute;");
expect(css).toContain(".chat-controls__session-row");
expect(css).toContain(".chat-controls__thinking-select");
expect(css).toContain('grid-template-areas: "agent session model";');
});
it("lays out mobile chat header action icons as an even full-width grid", () => {
@@ -40,7 +41,7 @@ describe("chat header responsive mobile styles", () => {
expect(css).toContain(
".chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking",
);
expect(css).toContain("grid-template-columns: repeat(5, minmax(0, 1fr));");
expect(css).toContain("grid-template-columns: repeat(4, minmax(0, 1fr));");
expect(css).toContain(
".chat-mobile-controls-wrapper .chat-controls-dropdown .btn--icon {\n width: 100%;",
);
@@ -56,51 +57,22 @@ describe("chat header responsive mobile styles", () => {
expect(css).toContain("min-width: 44px;");
});
it("keeps focused chat from reserving hidden page-header height", () => {
const layoutCss = readLayoutCss();
const mobileCss = readMobileCss();
const focusedShell = selectorBlocks(layoutCss, ".shell--chat-focus").join("\n");
const focusedMobileShell = selectorBlocks(mobileCss, ".shell--chat-focus").join("\n");
const focusedTopbar = selectorBlocks(layoutCss, ".shell--chat-focus .topbar").join("\n");
const focusedHeaderSelector = ".shell--chat-focus .content--chat .content-header";
const expectedDeclarations = [
"min-height: 0;",
"max-height: 0;",
"padding-top: 0;",
"padding-bottom: 0;",
"overflow: hidden;",
];
expect(focusedShell).toContain("grid-template-rows: 0 minmax(0, 1fr);");
expect(focusedMobileShell).toContain("grid-template-rows: 0 minmax(0, 1fr);");
expect(focusedTopbar).toContain("min-height: 0;");
expect(focusedTopbar).toContain("height: 0;");
expect(focusedTopbar).toContain("padding-top: 0;");
expect(focusedTopbar).toContain("padding-bottom: 0;");
expect(focusedTopbar).toContain("overflow: hidden;");
for (const css of [layoutCss, mobileCss]) {
const block = selectorBlocks(css, focusedHeaderSelector).join("\n");
expect(block).toBeTruthy();
for (const declaration of expectedDeclarations) {
expect(block).toContain(declaration);
}
}
});
it("restores single-page logs scrolling on mobile", () => {
const mobileCss = readMobileCss();
const logsBlock = selectorBlocks(mobileCss, ".content.content--logs").join("\n");
const workspaceBlock = selectorBlocks(
mobileCss,
".content.content--logs .settings-workspace",
).join("\n");
const logStreamBlock = selectorBlocks(
mobileCss,
".card--fill-height.card--fill-height .log-stream",
).join("\n");
expect(mobileCss).toContain(".content.content--logs {");
expect(mobileCss).toMatch(
/\.content\.content--logs \{[\s\S]*display: block;[\s\S]*overflow-y: auto;/,
);
expect(mobileCss).toMatch(
/\.content\.content--logs \.settings-workspace \{[\s\S]*display: block;/,
);
expect(mobileCss).toMatch(
/\.card--fill-height\.card--fill-height \.log-stream \{[\s\S]*max-height: 380px;/,
);
expect(logsBlock).toContain("display: block;");
expect(logsBlock).toContain("overflow-y: auto;");
expect(workspaceBlock).toContain("display: block;");
expect(logStreamBlock).toContain("max-height: 380px;");
});
});
@@ -121,30 +93,42 @@ describe("sidebar menu trigger styles", () => {
it("keeps the sidebar new-session button inset and its icon visible", () => {
const css = readLayoutCss();
const sessionsBlock = selectorBlocks(css, ".sidebar-sessions").join("\n");
const newSessionBlock = selectorBlocks(css, ".sidebar-new-session").join("\n");
const newSessionIconBlock = selectorBlocks(css, ".sidebar-new-session__icon svg").join("\n");
const collapsedSessionsBlock = selectorBlocks(
css,
".sidebar--collapsed .sidebar-sessions",
).join("\n");
expect(css).toMatch(/\.sidebar-sessions \{[\s\S]*padding: 0 8px;/);
expect(css).toMatch(/\.sidebar-new-session \{[\s\S]*min-height: 38px;/);
expect(css).toMatch(/\.sidebar-new-session \{[\s\S]*box-sizing: border-box;/);
expect(css).toMatch(
/\.sidebar-new-session__icon svg \{[\s\S]*stroke: currentColor;[\s\S]*fill: none;/,
);
expect(css).toMatch(/\.sidebar--collapsed \.sidebar-sessions \{[\s\S]*padding: 0;/);
expect(sessionsBlock).toContain("padding: 0 8px;");
expect(newSessionBlock).toContain("min-height: 38px;");
expect(newSessionBlock).toContain("box-sizing: border-box;");
expect(newSessionIconBlock).toContain("stroke: currentColor;");
expect(newSessionIconBlock).toContain("fill: none;");
expect(collapsedSessionsBlock).toContain("padding: 0;");
});
});
describe("topbar theme mode tooltip styles", () => {
it("clamps the rightmost color mode tooltip inside the viewport edge", () => {
const css = readLayoutCss();
const lastChildAfterBlock = selectorBlocks(
css,
".topbar-theme-mode__btn:last-child[data-tooltip]::after",
).join("\n");
const lastChildHoverAfterBlock = selectorBlocks(
css,
".topbar-theme-mode__btn:last-child[data-tooltip]:hover::after",
).join("\n");
const lastChildFocusAfterBlock = selectorBlocks(
css,
".topbar-theme-mode__btn:last-child[data-tooltip]:focus-visible::after",
).join("\n");
expect(css).toMatch(
/\.topbar-theme-mode__btn:last-child\[data-tooltip\]::after \{[\s\S]*right: 0;/,
);
expect(css).toMatch(
/\.topbar-theme-mode__btn:last-child\[data-tooltip\]:hover::after \{[\s\S]*transform: translateY\(0\);/,
);
expect(css).toMatch(
/\.topbar-theme-mode__btn:last-child\[data-tooltip\]:focus-visible::after \{[\s\S]*transform: translateY\(0\);/,
);
expect(lastChildAfterBlock).toContain("right: 0;");
expect(lastChildHoverAfterBlock).toContain("transform: translateY(0);");
expect(lastChildFocusAfterBlock).toContain("transform: translateY(0);");
const tooltipBlock =
selectorBlocks(css, ".topbar-theme-mode__btn[data-tooltip]::after").find((block) =>
block.includes("content: attr(data-tooltip);"),

View File

@@ -389,6 +389,17 @@ function installControlUiMockGateway(input: {
mainKey: "main",
scope: "agent",
};
case "agents.files.list":
return {
agentId:
isRecord(params) && typeof params.agentId === "string"
? params.agentId
: scenario.defaultAgentId,
files: [],
workspace: "",
};
case "agents.files.get":
return null;
case "chat.history":
return {
messages: scenario.historyMessages,

View File

@@ -1315,7 +1315,7 @@ export async function handleSendChat(
}
function shouldQueueLocalSlashCommand(name: string): boolean {
return !["stop", "focus", "export-session", "steer", "redirect", "new"].includes(name);
return !["stop", "export-session", "steer", "redirect", "new"].includes(name);
}
// ── Slash Command Dispatch ──
@@ -1347,9 +1347,6 @@ async function dispatchSlashCommand(
case "clear":
await clearChatHistory(host);
return;
case "focus":
await host.onSlashAction?.("toggle-focus");
return;
case "export-session":
await host.onSlashAction?.("export");
return;

View File

@@ -139,7 +139,6 @@ function createHost(): TestGatewayHost {
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,

View File

@@ -119,7 +119,6 @@ function createHost() {
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,

View File

@@ -60,7 +60,6 @@ function createState(overrides: Partial<AppViewState> = {}): AppViewState {
navGroupsCollapsed: {},
borderRadius: 50,
textScale: 100,
chatFocusMode: false,
chatShowThinking: false,
chatShowToolCalls: true,
},

View File

@@ -48,7 +48,6 @@ function createState(overrides: Partial<AppViewState> = {}) {
navCollapsed: false,
navGroupsCollapsed: {},
borderRadius: 50,
chatFocusMode: false,
chatShowThinking: false,
chatShowToolCalls: true,
chatAutoScroll: "near-bottom",
@@ -72,20 +71,6 @@ function createState(overrides: Partial<AppViewState> = {}) {
} as unknown as AppViewState;
}
function renderRefreshButton(overrides: Partial<AppViewState> = {}) {
const container = document.createElement("div");
render(renderChatControls(createState(overrides)), container);
const button = container.querySelector<HTMLButtonElement>(
`.chat-controls .btn--icon[data-tooltip="${t("chat.refreshTitle")}"]`,
);
expect(button).toBeInstanceOf(HTMLButtonElement);
if (!(button instanceof HTMLButtonElement)) {
throw new Error("Expected chat refresh button");
}
return button;
}
function requireButton(
button: HTMLButtonElement | null | undefined,
label: string,
@@ -128,18 +113,19 @@ describe("chat header controls (browser)", () => {
await Promise.resolve();
const buttons = Array.from(
container.querySelectorAll<HTMLButtonElement>(".chat-controls .btn--icon[data-tooltip]"),
container.querySelectorAll<HTMLButtonElement>(
".chat-settings-popover__toggles .btn--icon[data-tooltip]",
),
);
expect(buttons).toHaveLength(6);
expect(buttons).toHaveLength(5);
const labels = buttons.map((button) => button.getAttribute("data-tooltip"));
expect(labels).toEqual([
t("chat.refreshTitle"),
t("common.refresh"),
`${t("chat.autoScrollMode")}: ${t("chat.autoScrollNearBottom")}`,
t("chat.thinkingToggle"),
t("chat.toolCallsToggle"),
t("chat.focusToggle"),
t("chat.showCronSessions"),
]);
@@ -174,19 +160,6 @@ describe("chat header controls (browser)", () => {
expect(buttons[0]?.classList.contains("topbar-theme-mode__btn--active")).toBe(true);
});
it.each([
["connected and idle", {}, false],
["chat history loading", { chatLoading: true }, true],
["chat send in flight", { chatSending: true }, true],
["active run", { chatRunId: "run-123" }, true],
["active stream", { chatStream: "streaming" }, true],
["disconnected", { connected: false }, true],
] as const)("sets refresh disabled state while %s", (_name, overrides, disabled) => {
const button = renderRefreshButton(overrides);
expect(button.disabled).toBe(disabled);
});
it("renders the cron session filter in the mobile dropdown controls", async () => {
const state = createState({
sessionKey: "agent:alpha:main",
@@ -219,7 +192,7 @@ describe("chat header controls (browser)", () => {
container.querySelectorAll<HTMLButtonElement>(".chat-controls__thinking .btn--icon"),
);
expect(buttons).toHaveLength(5);
expect(buttons).toHaveLength(4);
const autoScrollButton = requireButton(buttons.at(0), "auto-scroll mode");
expect(autoScrollButton.dataset.chatAutoScrollMode).toBe("near-bottom");
const cronButton = requireButton(buttons.at(-1), "cron sessions");
@@ -297,10 +270,14 @@ describe("chat header controls (browser)", () => {
const selectDatasets = Array.from(container.querySelectorAll("select")).map(
(select) => select.dataset,
);
expect(selectDatasets).toHaveLength(3);
expect(selectDatasets).toHaveLength(1);
expect(selectDatasets[0]?.chatAgentFilter).toBe("true");
expect(selectDatasets[1]?.chatModelSelect).toBe("true");
expect(selectDatasets[2]?.chatThinkingSelect).toBe("true");
expect(container.querySelector<HTMLElement>('[data-chat-model-select="true"]')?.tagName).toBe(
"SUMMARY",
);
expect(
container.querySelector<HTMLElement>('[data-chat-thinking-select="true"]')?.tagName,
).toBe("SUMMARY");
const autoScrollToggle = requireButton(
container.querySelector<HTMLButtonElement>('[data-chat-auto-scroll-toggle="true"]'),
"auto-scroll toggle",

View File

@@ -134,7 +134,6 @@ function createSettings(): AppViewState["settings"] {
navCollapsed: false,
navGroupsCollapsed: {},
borderRadius: 50,
chatFocusMode: false,
chatShowThinking: false,
chatShowToolCalls: true,
};
@@ -965,44 +964,6 @@ describe("createChatSession", () => {
});
describe("switchChatSession", () => {
it("can wait for the initial history and message subscription before callers send", async () => {
let resolveHistory!: () => void;
let resolveSubscription!: () => void;
const historyLoaded = new Promise<void>((resolve) => {
resolveHistory = resolve;
});
const subscriptionSynced = new Promise<void>((resolve) => {
resolveSubscription = resolve;
});
const state = createChatSessionState({
sessionKey: "agent:main:main",
chatQueueBySession: {},
announceSessionSwitch: vi.fn(),
});
loadChatHistoryMock.mockReturnValue(historyLoaded);
syncSelectedSessionMessageSubscriptionMock.mockReturnValue(subscriptionSynced);
loadSessionsMock.mockResolvedValue(undefined);
const switched = switchChatSession(state, "agent:main:review", {
awaitInitialLoad: true,
});
let settled = false;
void switched?.then(() => {
settled = true;
});
await Promise.resolve();
expect(settled).toBe(false);
resolveHistory();
await Promise.resolve();
expect(settled).toBe(false);
resolveSubscription();
await switched;
expect(settled).toBe(true);
});
it("refreshes the chat avatar after clearing session-scoped state", async () => {
const settings = createSettings();
const state = {
@@ -1061,7 +1022,7 @@ describe("switchChatSession", () => {
loadChatHistoryMock.mockResolvedValue(undefined);
loadSessionsMock.mockResolvedValue(undefined);
void switchChatSession(state, "agent:main:test-b");
switchChatSession(state, "agent:main:test-b");
await Promise.resolve();
expect(state.chatQueue).toStrictEqual([]);
@@ -1133,10 +1094,10 @@ describe("switchChatSession", () => {
loadChatHistoryMock.mockResolvedValue(undefined);
loadSessionsMock.mockResolvedValue(undefined);
void switchChatSession(state, "agent:main:other");
switchChatSession(state, "agent:main:other");
expect(state.chatQueue).toStrictEqual([]);
void switchChatSession(state, "main");
switchChatSession(state, "main");
expect(state.chatQueue).toEqual([{ id: "queued-1", text: "message B", createdAt: 1 }]);
});
@@ -1180,7 +1141,7 @@ describe("switchChatSession", () => {
loadChatHistoryMock.mockResolvedValue(undefined);
loadSessionsMock.mockResolvedValue(undefined);
void switchChatSession(state, "main");
switchChatSession(state, "main");
await Promise.resolve();
expect(

View File

@@ -13,6 +13,7 @@ import { persistChatComposerState, restoreChatComposerState } from "./chat/compo
import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts";
import {
renderChatSessionSelect as renderChatSessionSelectBase,
renderChatModelSelect,
resetChatSessionPickerState,
resolveSessionOptionGroups,
} from "./chat/session-controls.ts";
@@ -199,19 +200,14 @@ const NEW_CHAT_SESSIONS_LOADING_MESSAGE =
const NEW_CHAT_CREATE_FAILED_MESSAGE =
"New Chat could not create a new session. Try again in a moment.";
export function renderTab(
state: AppViewState,
tab: Tab,
opts?: { collapsed?: boolean; child?: boolean },
) {
export function renderTab(state: AppViewState, tab: Tab, opts?: { collapsed?: boolean }) {
const href = pathForTab(tab, state.basePath);
const isActive = tab === "config" ? isSettingsTab(state.tab) : state.tab === tab;
const collapsed = opts?.collapsed ?? state.settings.navCollapsed;
const isChild = opts?.child === true;
return html`
<a
href=${href}
class="nav-item ${isActive ? "nav-item--active" : ""} ${isChild ? "nav-item--child" : ""}"
class="nav-item ${isActive ? "nav-item--active" : ""}"
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
@@ -282,13 +278,7 @@ function renderCronFilterIcon(hiddenCount: number) {
}
export function renderChatSessionSelect(state: AppViewState) {
return renderChatSessionSelectBase(
state,
(targetState, nextSessionKey) => {
void switchChatSession(targetState, nextSessionKey);
},
{ surface: "desktop" },
);
return renderChatSessionSelectBase(state, switchChatSession, { surface: "desktop" });
}
function chatAutoScrollLabel(mode: ChatAutoScrollMode) {
@@ -315,13 +305,15 @@ function nextChatAutoScrollMode(mode: ChatAutoScrollMode): ChatAutoScrollMode {
return "near-bottom";
}
function renderChatAutoScrollToggle(state: AppViewState) {
function renderChatAutoScrollToggle(state: AppViewState, options: { labelled?: boolean } = {}) {
const mode = normalizeChatAutoScrollMode(state.settings.chatAutoScroll);
const label = `${t("chat.autoScrollMode")}: ${chatAutoScrollLabel(mode)}`;
const active = mode !== "off";
return html`
<button
class="btn btn--sm btn--icon ${active ? "active" : ""}"
class="btn btn--sm btn--icon ${options.labelled ? "chat-settings-action" : ""} ${active
? "active"
: ""}"
data-chat-auto-scroll-toggle="true"
data-chat-auto-scroll-mode=${mode}
data-tooltip=${label}
@@ -336,6 +328,9 @@ function renderChatAutoScrollToggle(state: AppViewState) {
}}
>
${icons.scrollText}
${options.labelled
? html`<span class="chat-settings-action__text">${t("chat.autoScrollMode")}</span>`
: ""}
</button>
`;
}
@@ -344,29 +339,26 @@ export function renderChatControls(state: AppViewState) {
const hideCron = state.sessionsHideCron ?? true;
const hiddenCronCount = hideCron ? countHiddenCronSessions(state, state.sessionsResult) : 0;
const disableThinkingToggle = state.onboarding;
const disableFocusToggle = state.onboarding;
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
const refreshLabel = t("chat.refreshTitle");
const thinkingLabel = disableThinkingToggle
? t("chat.onboardingDisabled")
: t("chat.thinkingToggle");
const toolCallsLabel = disableThinkingToggle
? t("chat.onboardingDisabled")
: t("chat.toolCallsToggle");
const focusLabel = disableFocusToggle ? t("chat.onboardingDisabled") : t("chat.focusToggle");
const refreshDisabled =
!state.connected ||
state.chatManualRefreshInFlight ||
state.chatLoading ||
state.chatSending ||
state.chatStream !== null ||
Boolean(state.chatRunId);
const cronLabel = hideCron
? hiddenCronCount > 0
? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) })
: t("chat.showCronSessions")
: t("chat.hideCronSessions");
const refreshDisabled =
!state.connected ||
state.chatLoading ||
state.chatSending ||
Boolean(state.chatRunId) ||
state.chatStream !== null;
const toolCallsIcon = html`
<svg
width="18"
@@ -383,122 +375,125 @@ export function renderChatControls(state: AppViewState) {
></path>
</svg>
`;
const refreshIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
</svg>
`;
const focusIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 7V4h3"></path>
<path d="M20 7V4h-3"></path>
<path d="M4 17v3h3"></path>
<path d="M20 17v3h-3"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
`;
const settingsOpen = state.chatMobileControlsOpen;
const settingsLabel = t("chat.settings");
const settingsTitle = t("chat.settings");
return html`
<div class="chat-controls">
<div
class="chat-composer-model-control"
@click=${() => {
if (state.chatMobileControlsOpen) {
state.setChatMobileControlsOpen(false);
}
}}
>
${renderChatModelSelect(state)}
</div>
<div class="chat-settings-popover-wrapper">
<button
class="btn btn--sm btn--icon"
?disabled=${refreshDisabled}
@click=${() => handleChatManualRefresh(state as unknown as ChatRefreshHost)}
title=${refreshLabel}
aria-label=${refreshLabel}
data-tooltip=${refreshLabel}
>
${refreshIcon}
</button>
<span class="chat-controls__separator">|</span>
${renderChatAutoScrollToggle(state)}
<button
class="btn btn--sm btn--icon ${showThinking ? "active" : ""}"
?disabled=${disableThinkingToggle}
@click=${() => {
if (disableThinkingToggle) {
return;
}
state.applySettings({
...state.settings,
chatShowThinking: !state.settings.chatShowThinking,
class="chat-settings-chip ${settingsOpen ? "chat-settings-chip--open" : ""}"
type="button"
title=${settingsTitle}
aria-label=${settingsTitle}
aria-expanded=${settingsOpen}
aria-controls="chat-composer-settings-popover"
@click=${(e: Event) => {
e.stopPropagation();
(e.currentTarget as HTMLElement)
.closest(".agent-chat__composer-controls")
?.querySelectorAll("details.chat-controls__inline-select[open]")
.forEach((details) => details.removeAttribute("open"));
state.setChatMobileControlsOpen(!settingsOpen, {
trigger: e.currentTarget as HTMLElement,
});
}}
aria-pressed=${showThinking}
title=${thinkingLabel}
aria-label=${thinkingLabel}
data-tooltip=${thinkingLabel}
>
${icons.brain}
<span class="chat-settings-chip__icon">${icons.settings}</span>
<span class="chat-settings-chip__text">${settingsLabel}</span>
<span class="chat-settings-chip__chevron">${icons.chevronDown}</span>
</button>
<button
class="btn btn--sm btn--icon ${showToolCalls ? "active" : ""}"
?disabled=${disableThinkingToggle}
@click=${() => {
if (disableThinkingToggle) {
return;
}
state.applySettings({
...state.settings,
chatShowToolCalls: !state.settings.chatShowToolCalls,
});
}}
aria-pressed=${showToolCalls}
title=${toolCallsLabel}
aria-label=${toolCallsLabel}
data-tooltip=${toolCallsLabel}
<div
id="chat-composer-settings-popover"
class="chat-settings-popover ${settingsOpen ? "chat-settings-popover--open" : ""}"
role="dialog"
aria-label=${settingsTitle}
>
${toolCallsIcon}
</button>
<button
class="btn btn--sm btn--icon ${focusActive ? "active" : ""}"
?disabled=${disableFocusToggle}
@click=${() => {
if (disableFocusToggle) {
return;
}
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
}}
aria-pressed=${focusActive}
title=${focusLabel}
aria-label=${focusLabel}
data-tooltip=${focusLabel}
>
${focusIcon}
</button>
<button
class="btn btn--sm btn--icon ${hideCron ? "active" : ""}"
@click=${() => {
state.sessionsHideCron = !hideCron;
}}
aria-pressed=${hideCron}
title=${cronLabel}
aria-label=${cronLabel}
data-tooltip=${cronLabel}
>
${renderCronFilterIcon(hiddenCronCount)}
</button>
<div class="chat-settings-popover__section">
<span class="chat-settings-popover__label">${settingsLabel}</span>
<div class="chat-settings-popover__toggles">
<button
class="btn btn--sm btn--icon chat-settings-action"
?disabled=${refreshDisabled}
@click=${() => {
if (!refreshDisabled) {
void handleChatManualRefresh(state as ChatRefreshHost);
}
}}
title=${t("common.refresh")}
aria-label=${t("common.refresh")}
data-tooltip=${t("common.refresh")}
>
${icons.refresh}
<span class="chat-settings-action__text">${t("common.refresh")}</span>
</button>
${renderChatAutoScrollToggle(state, { labelled: true })}
<button
class="btn btn--sm btn--icon chat-settings-action ${showThinking ? "active" : ""}"
?disabled=${disableThinkingToggle}
@click=${() => {
if (disableThinkingToggle) {
return;
}
state.applySettings({
...state.settings,
chatShowThinking: !state.settings.chatShowThinking,
});
}}
aria-pressed=${showThinking}
title=${thinkingLabel}
aria-label=${thinkingLabel}
data-tooltip=${thinkingLabel}
>
${icons.brain}
<span class="chat-settings-action__text">${t("cron.form.thinking")}</span>
</button>
<button
class="btn btn--sm btn--icon chat-settings-action ${showToolCalls ? "active" : ""}"
?disabled=${disableThinkingToggle}
@click=${() => {
if (disableThinkingToggle) {
return;
}
state.applySettings({
...state.settings,
chatShowToolCalls: !state.settings.chatShowToolCalls,
});
}}
aria-pressed=${showToolCalls}
title=${toolCallsLabel}
aria-label=${toolCallsLabel}
data-tooltip=${toolCallsLabel}
>
${toolCallsIcon}
<span class="chat-settings-action__text">${t("agents.tabs.tools")}</span>
</button>
<button
class="btn btn--sm btn--icon chat-settings-action ${hideCron ? "active" : ""}"
@click=${() => {
state.sessionsHideCron = !hideCron;
}}
aria-pressed=${hideCron}
title=${cronLabel}
aria-label=${cronLabel}
data-tooltip=${cronLabel}
>
${renderCronFilterIcon(hiddenCronCount)}
<span class="chat-settings-action__text">${t("cron.jobList.history")}</span>
</button>
</div>
</div>
</div>
</div>
`;
}
@@ -512,10 +507,8 @@ export function renderChatMobileToggle(state: AppViewState) {
const controlsDropdownId = "chat-mobile-controls-dropdown";
const mobileControlsOpen = state.chatMobileControlsOpen;
const disableThinkingToggle = state.onboarding;
const disableFocusToggle = state.onboarding;
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
const hideCron = state.sessionsHideCron ?? true;
const hiddenCronCount = hideCron ? countHiddenCronSessions(state, state.sessionsResult) : 0;
const toolCallsIcon = html`
@@ -534,24 +527,6 @@ export function renderChatMobileToggle(state: AppViewState) {
></path>
</svg>
`;
const focusIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 7V4h3"></path>
<path d="M20 7V4h-3"></path>
<path d="M4 17v3h3"></path>
<path d="M20 17v3h-3"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
`;
return html`
<div class="chat-mobile-controls-wrapper">
@@ -592,13 +567,7 @@ export function renderChatMobileToggle(state: AppViewState) {
}}
>
<div class="chat-controls">
${renderChatSessionSelectBase(
state,
(targetState, nextSessionKey) => {
void switchChatSession(targetState, nextSessionKey);
},
{ surface: "mobile" },
)}
${renderChatSessionSelectBase(state, switchChatSession, { surface: "mobile" })}
<div class="chat-controls__thinking">
${renderChatAutoScrollToggle(state)}
<button
@@ -633,22 +602,6 @@ export function renderChatMobileToggle(state: AppViewState) {
>
${toolCallsIcon}
</button>
<button
class="btn btn--sm btn--icon ${focusActive ? "active" : ""}"
?disabled=${disableFocusToggle}
@click=${() => {
if (!disableFocusToggle) {
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
}
}}
aria-pressed=${focusActive}
title=${t("chat.focusToggle")}
>
${focusIcon}
</button>
<button
class="btn btn--sm btn--icon ${hideCron ? "active" : ""}"
@click=${() => {
@@ -670,11 +623,7 @@ export function renderChatMobileToggle(state: AppViewState) {
`;
}
export function switchChatSession(
state: AppViewState,
nextSessionKey: string,
opts?: { awaitInitialLoad?: boolean },
): Promise<void> | undefined {
export function switchChatSession(state: AppViewState, nextSessionKey: string) {
const previousSessionKey = state.sessionKey;
const nextSessionRow =
state.sessionsResult?.sessions.find((row) => row.key === nextSessionKey) ??
@@ -695,19 +644,11 @@ export function switchChatSession(
nextSessionKey,
true,
);
const subscriptionSync = syncSelectedSessionMessageSubscription(
void syncSelectedSessionMessageSubscription(
state as unknown as AppViewState & { chatSessionMessageSubscriptionKey?: string | null },
);
const historyLoad = loadChatHistory(state as unknown as ChatState);
const sessionsRefresh = refreshSessionOptions(state);
if (opts?.awaitInitialLoad) {
void sessionsRefresh;
return Promise.allSettled([subscriptionSync, historyLoad]).then(() => undefined);
}
void subscriptionSync;
void historyLoad;
void sessionsRefresh;
return undefined;
void loadChatHistory(state as unknown as ChatState);
void refreshSessionOptions(state);
}
export function dismissChatError(state: AppViewState) {
@@ -783,7 +724,7 @@ export async function createChatSession(state: AppViewState): Promise<boolean> {
const preservedDraft = state.chatMessage;
const preservedAttachments = state.chatAttachments;
void switchChatSession(state, nextSessionKey);
switchChatSession(state, nextSessionKey);
state.chatMessage = preservedDraft;
state.chatAttachments = preservedAttachments;
return true;

File diff suppressed because it is too large Load Diff

View File

@@ -36,7 +36,7 @@ function createScrollHost(
overflowY,
} as unknown as CSSStyleDeclaration);
const settings: { chatAutoScroll?: ChatAutoScrollMode; chatFocusMode?: boolean } = {};
const settings: { chatAutoScroll?: ChatAutoScrollMode } = {};
if (chatAutoScroll) {
settings.chatAutoScroll = chatAutoScroll;
}
@@ -146,25 +146,6 @@ describe("handleChatScroll", () => {
expect(host.chatHeaderControlsHidden).toBe(false);
});
it("does not toggle header controls while focus mode handles hidden chrome", () => {
const { host } = createScrollHost({});
host.settings = { ...host.settings, chatFocusMode: true };
host.chatLastScrollTop = 100;
host.chatHeaderControlsHidden = false;
handleChatScroll(host, createScrollEvent(3000, 260, 500));
expect(host.chatHeaderControlsHidden).toBe(false);
expect(host.chatUserNearBottom).toBe(false);
host.chatHeaderControlsHidden = true;
host.chatLastScrollTop = 800;
handleChatScroll(host, createScrollEvent(3000, 700, 500));
expect(host.chatHeaderControlsHidden).toBe(true);
});
});
/* ------------------------------------------------------------------ */

View File

@@ -20,7 +20,6 @@ type ScrollHost = {
chatProgrammaticScrollTarget: number;
settings?: {
chatAutoScroll?: ChatAutoScrollMode;
chatFocusMode?: boolean;
};
logsScrollFrame: number | null;
logsAtBottom: boolean;
@@ -217,14 +216,12 @@ export function handleChatScroll(host: ScrollHost, event: Event) {
host.chatUserNearBottom = distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
const hasUsefulScroll = container.scrollHeight - container.clientHeight > NEAR_BOTTOM_THRESHOLD;
if (!host.settings?.chatFocusMode) {
if (!hasUsefulScroll || scrollTop <= HEADER_SHOW_TOP_THRESHOLD || host.chatUserNearBottom) {
host.chatHeaderControlsHidden = false;
} else if (delta > HEADER_HIDE_SCROLL_DELTA) {
host.chatHeaderControlsHidden = true;
} else if (delta < -HEADER_HIDE_SCROLL_DELTA) {
host.chatHeaderControlsHidden = false;
}
if (!hasUsefulScroll || scrollTop <= HEADER_SHOW_TOP_THRESHOLD || host.chatUserNearBottom) {
host.chatHeaderControlsHidden = false;
} else if (delta > HEADER_HIDE_SCROLL_DELTA) {
host.chatHeaderControlsHidden = true;
} else if (delta < -HEADER_HIDE_SCROLL_DELTA) {
host.chatHeaderControlsHidden = false;
}
// Clear the "new messages below" indicator when user scrolls back to bottom.

View File

@@ -38,7 +38,6 @@ type SettingsHost = {
lastActiveSessionKey: string;
theme: ThemeName;
themeMode: ThemeMode;
chatFocusMode: boolean;
chatShowThinking: boolean;
chatShowToolCalls: boolean;
splitRatio: number;
@@ -138,7 +137,6 @@ const createHost = (tab: Tab): SettingsHost => ({
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,

View File

@@ -51,7 +51,6 @@ import type {
} from "./types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
import type { SkillWorkshopProposal } from "./views/skill-workshop.ts";
import type { SessionLogEntry } from "./views/usage.ts";
export type AppViewState = {
@@ -129,7 +128,7 @@ export type AppViewState = {
sessionSwitchNotice: { id: number; text: string } | null;
sessionSwitchFlashKey: string | null;
chatSessionPickerOpen: boolean;
chatSessionPickerSurface: "desktop" | "mobile" | null;
chatSessionPickerSurface: "desktop" | "mobile" | "sidebar" | null;
chatSessionPickerQuery: string;
chatSessionPickerAppliedQuery: string;
chatSessionPickerLoading: boolean;
@@ -426,34 +425,6 @@ export type AppViewState = {
skillCardContentKeys: Record<string, string>;
skillCardLoadingKey: string | null;
skillCardErrors: Record<string, string>;
skillWorkshopSelectedKey: string | null;
skillWorkshopStatusFilter: "all" | "pending" | "applied" | "rejected" | "quarantined" | "stale";
skillWorkshopMode: "board" | "today";
skillWorkshopQuery: string;
skillWorkshopFilePreviewKey: string | null;
skillWorkshopFilePreviewQuery: string;
skillWorkshopLoading: boolean;
skillWorkshopLoaded: boolean;
skillWorkshopError: string | null;
skillWorkshopInspectingKey: string | null;
skillWorkshopProposals: SkillWorkshopProposal[];
skillWorkshopReviewedKeys: string[];
skillWorkshopQueueWidth: number;
skillWorkshopUseCurrentChatForRevisions: boolean;
skillWorkshopRevisionSessions: Record<string, { sessionKey: string; updatedAt: number }>;
skillWorkshopActionBusy: { key: string; action: "apply" | "revise" | "reject" } | null;
skillWorkshopActionNotice: { key: string; label: string; slug: string } | null;
skillWorkshopRevisionKey: string | null;
skillWorkshopRevisionDraft: string;
skillWorkshopActionNoticeTimer?: ReturnType<typeof globalThis.setTimeout> | number | null;
skillWorkshopChatHandoffActive?: boolean;
skillWorkshopChatHandoffTimer?: ReturnType<typeof globalThis.setTimeout> | number | null;
skillWorkshopHandoff: {
key: string;
slug: string;
phase: "prepare" | "landing" | "error";
} | null;
skillWorkshopHandoffDismissTimer?: ReturnType<typeof globalThis.setTimeout> | number | null;
healthLoading: boolean;
healthResult: HealthSummary | null;
healthError: string | null;

View File

@@ -42,14 +42,7 @@ import {
} from "./app-lifecycle.ts";
import { initNativeBridge } from "./app-native-bridge.ts";
import { createChatSession as createChatSessionInternal } from "./app-render.helpers.ts";
import {
loadSkillWorkshopQueueWidth,
loadSkillWorkshopMode,
loadSkillWorkshopReviewedKeys,
loadSkillWorkshopRevisionSessions,
loadSkillWorkshopUseCurrentChatForRevisions,
renderApp,
} from "./app-render.ts";
import { renderApp } from "./app-render.ts";
import {
exportLogs as exportLogsInternal,
handleActivityScroll as handleActivityScrollInternal,
@@ -151,7 +144,6 @@ import type {
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types.ts";
import { generateUUID } from "./uuid.ts";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
import type { SkillWorkshopProposal } from "./views/skill-workshop.ts";
declare global {
interface Window {
@@ -287,7 +279,7 @@ export class OpenClawApp extends LitElement {
@state() sessionSwitchNotice: { id: number; text: string } | null = null;
@state() sessionSwitchFlashKey: string | null = null;
@state() chatSessionPickerOpen = false;
@state() chatSessionPickerSurface: "desktop" | "mobile" | null = null;
@state() chatSessionPickerSurface: "desktop" | "mobile" | "sidebar" | null = null;
@state() chatSessionPickerQuery = "";
@state() chatSessionPickerAppliedQuery = "";
@state() chatSessionPickerLoading = false;
@@ -630,42 +622,6 @@ export class OpenClawApp extends LitElement {
@state() skillCardLoadingKey: string | null = null;
@state() skillCardErrors: Record<string, string> = {};
@state() skillWorkshopSelectedKey: string | null = null;
@state() skillWorkshopStatusFilter:
| "all"
| "pending"
| "applied"
| "rejected"
| "quarantined"
| "stale" = "all";
@state() skillWorkshopQuery = "";
@state() skillWorkshopFilePreviewKey: string | null = null;
@state() skillWorkshopFilePreviewQuery = "";
@state() skillWorkshopLoading = false;
@state() skillWorkshopLoaded = false;
@state() skillWorkshopError: string | null = null;
@state() skillWorkshopInspectingKey: string | null = null;
@state() skillWorkshopProposals: SkillWorkshopProposal[] = [];
@state() skillWorkshopReviewedKeys = loadSkillWorkshopReviewedKeys();
@state() skillWorkshopQueueWidth = loadSkillWorkshopQueueWidth();
@state() skillWorkshopMode: "board" | "today" = loadSkillWorkshopMode();
@state() skillWorkshopUseCurrentChatForRevisions = loadSkillWorkshopUseCurrentChatForRevisions();
@state() skillWorkshopRevisionSessions = loadSkillWorkshopRevisionSessions();
@state() skillWorkshopActionBusy: { key: string; action: "apply" | "revise" | "reject" } | null =
null;
@state() skillWorkshopActionNotice: { key: string; label: string; slug: string } | null = null;
@state() skillWorkshopRevisionKey: string | null = null;
@state() skillWorkshopRevisionDraft = "";
skillWorkshopActionNoticeTimer: ReturnType<typeof globalThis.setTimeout> | number | null = null;
@state() skillWorkshopChatHandoffActive = false;
skillWorkshopChatHandoffTimer: ReturnType<typeof globalThis.setTimeout> | number | null = null;
@state() skillWorkshopHandoff: {
key: string;
slug: string;
phase: "prepare" | "landing" | "error";
} | null = null;
skillWorkshopHandoffDismissTimer: ReturnType<typeof globalThis.setTimeout> | number | null = null;
@state() healthLoading = false;
@state() healthResult: HealthSummary | null = null;
@state() healthError: string | null = null;
@@ -769,7 +725,9 @@ export class OpenClawApp extends LitElement {
if (!this.chatMobileControlsOpen) {
return;
}
const wrapper = this.querySelector(".chat-mobile-controls-wrapper");
const wrapper =
this.querySelector(".chat-settings-popover-wrapper") ??
this.querySelector(".chat-mobile-controls-wrapper");
if (wrapper && path.includes(wrapper)) {
return;
}
@@ -787,12 +745,6 @@ export class OpenClawApp extends LitElement {
case "new-session":
await createChatSessionInternal(this as unknown as AppViewState);
break;
case "toggle-focus":
this.applySettings({
...this.settings,
chatFocusMode: !this.settings.chatFocusMode,
});
break;
case "export":
exportChatMarkdown(this.chatMessages, this.assistantName);
break;
@@ -828,18 +780,6 @@ export class OpenClawApp extends LitElement {
window.clearTimeout(this.sessionSwitchFlashTimer);
this.sessionSwitchFlashTimer = null;
}
if (this.skillWorkshopActionNoticeTimer !== null) {
window.clearTimeout(this.skillWorkshopActionNoticeTimer);
this.skillWorkshopActionNoticeTimer = null;
}
if (this.skillWorkshopChatHandoffTimer !== null) {
window.clearTimeout(this.skillWorkshopChatHandoffTimer);
this.skillWorkshopChatHandoffTimer = null;
}
if (this.skillWorkshopHandoffDismissTimer !== null) {
window.clearTimeout(this.skillWorkshopHandoffDismissTimer);
this.skillWorkshopHandoffDismissTimer = null;
}
this.chatMobileControlsTrigger = null;
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
super.disconnectedCallback();

View File

@@ -99,12 +99,9 @@ function chatControlsHtml(opts: { agent?: boolean } = {}) {
<label class="field chat-controls__session chat-controls__session-picker">
<select data-chat-session-select="true" aria-label="Chat session"><option>Daily planning</option></select>
</label>
<label class="field chat-controls__session chat-controls__model">
<select data-chat-model-select="true" aria-label="Chat model"><option>Default (gpt-5)</option></select>
</label>
<label class="field chat-controls__session chat-controls__thinking-select">
<select class="chat-controls__thinking-select-full" data-chat-thinking-select="true" aria-label="Chat thinking level"><option>Default (high)</option></select>
</label>
<details class="chat-controls__session chat-controls__inline-select chat-controls__model">
<summary class="chat-controls__inline-select-trigger" data-chat-model-select="true" data-chat-thinking-select="true" data-chat-select-value="" data-chat-thinking-value="" aria-label="Chat model">gpt-5 · High</summary>
</details>
</div>
<div class="chat-controls__thinking">
<button class="btn btn--sm btn--icon active">${iconSvg()}</button>
@@ -130,12 +127,9 @@ function chatHeaderControlsHtml(hidden = false) {
<label class="field chat-controls__session chat-controls__session-picker">
<select data-chat-session-select="true" aria-label="Chat session"><option>main</option></select>
</label>
<label class="field chat-controls__session chat-controls__model">
<select data-chat-model-select="true" aria-label="Chat model"><option>gpt-5.5</option></select>
</label>
<label class="field chat-controls__session chat-controls__thinking-select">
<select class="chat-controls__thinking-select-full" data-chat-thinking-select="true" aria-label="Chat thinking level"><option>Default (high)</option></select>
</label>
<details class="chat-controls__session chat-controls__inline-select chat-controls__model">
<summary class="chat-controls__inline-select-trigger" data-chat-model-select="true" data-chat-thinking-select="true" data-chat-select-value="gpt-5.5" data-chat-thinking-value="" aria-label="Chat model">gpt-5.5 · High</summary>
</details>
</div>
</div>
<div class="page-meta">
@@ -144,7 +138,6 @@ function chatHeaderControlsHtml(hidden = false) {
<span class="chat-controls__separator">|</span>
<button class="btn btn--sm btn--icon active" aria-label="Toggle assistant thinking">${iconSvg()}</button>
<button class="btn btn--sm btn--icon active" aria-label="Toggle tool calls">${iconSvg()}</button>
<button class="btn btn--sm btn--icon" aria-label="Toggle focus mode">${iconSvg()}</button>
<button class="btn btn--sm btn--icon active" aria-label="Show cron sessions">${iconSvg()}</button>
</div>
</div>
@@ -320,7 +313,6 @@ describeBrowserLayout("chat responsive browser layout", () => {
session: rectFor('[data-chat-session-select="true"]'),
agent: rectFor('[data-chat-agent-filter="true"]'),
model: rectFor('[data-chat-model-select="true"]'),
thinking: rectFor('[data-chat-thinking-select="true"]'),
action: rectFor(".page-meta .btn--icon"),
};
});
@@ -328,10 +320,9 @@ describeBrowserLayout("chat responsive browser layout", () => {
controls.session?.y,
controls.agent?.y,
controls.model?.y,
controls.thinking?.y,
controls.action?.y,
].filter((value): value is number => typeof value === "number");
expect(rowY.length).toBe(5);
expect(rowY.length).toBe(4);
expect(Math.max(...rowY) - Math.min(...rowY)).toBeLessThanOrEqual(4);
const agent = expectControlRect(controls.agent, "agent");
const session = expectControlRect(controls.session, "session");
@@ -460,7 +451,7 @@ describeBrowserLayout("chat responsive browser layout", () => {
await expectNoHorizontalOverflow(page);
const mobileControls = await page.evaluate(() => {
const rectFor = (selector: string) => {
const node = document.querySelector(selector) as HTMLSelectElement | null;
const node = document.querySelector(selector) as HTMLElement | null;
if (!node) {
return null;
}
@@ -470,26 +461,27 @@ describeBrowserLayout("chat responsive browser layout", () => {
y: rect.y,
width: rect.width,
height: rect.height,
text: node.options[node.selectedIndex]?.textContent?.trim() ?? "",
text: node.textContent?.trim() ?? "",
display: getComputedStyle(node).display,
};
};
return {
agent: rectFor('[data-chat-agent-filter="true"]'),
session: rectFor('[data-chat-session-select="true"]'),
thinkingFull: rectFor('[data-chat-thinking-select="true"]'),
model: rectFor('[data-chat-model-select="true"]'),
compactCount: document.querySelectorAll('[data-chat-thinking-select-compact="true"]')
.length,
};
});
const agent = expectControlRect(mobileControls.agent, "agent");
const session = expectControlRect(mobileControls.session, "session");
const model = expectControlRect(mobileControls.model, "model");
expect(session.y).toBe(agent.y);
expect(agent.x).toBeLessThan(session.x);
expect(session.width / agent.width).toBeGreaterThan(1.25);
expect(session.width / agent.width).toBeLessThan(1.55);
expect(mobileControls.thinkingFull?.display).not.toBe("none");
expect(mobileControls.thinkingFull?.text).toBe("Default (high)");
expect(model.display).not.toBe("none");
expect(model.text).toBe("gpt-5 · High");
expect(mobileControls.compactCount).toBe(0);
const sizes = await page
@@ -539,10 +531,8 @@ describeBrowserLayout("chat responsive browser layout", () => {
expect(await page.locator('[data-chat-agent-filter="true"]').count()).toBe(0);
const session = await getBoundingBox(page, '[data-chat-session-select="true"]');
const model = await getBoundingBox(page, '[data-chat-model-select="true"]');
const thinking = await getBoundingBox(page, '[data-chat-thinking-select="true"]');
expect(thinking.x).toBeGreaterThan(session.x);
expect(model.y).toBeGreaterThan(session.y);
expect(model.width).toBeGreaterThan(session.width);
expect(model.width).toBe(session.width);
} finally {
await page.close();
}

View File

@@ -102,14 +102,11 @@ vi.mock("../tool-display.ts", () => ({
formatToolDetail: () => undefined,
resolveToolDisplay: ({ name, args }: { name: string; args?: unknown }) => ({
name,
label: name === "skill_workshop" ? "Skill Workshop" : name,
label: name,
icon: "zap",
detail:
args && typeof args === "object" && ("detail" in args || "action" in args)
? String(
(args as { detail?: unknown; action?: unknown }).detail ??
(args as { action?: unknown }).action,
)
args && typeof args === "object" && "detail" in args
? String((args as { detail: unknown }).detail)
: undefined,
}),
}));
@@ -914,6 +911,128 @@ describe("grouped chat rendering", () => {
expect(avatar?.tagName).toBe("DIV");
});
it("collapses consecutive tool results into an activity group", () => {
const container = document.createElement("div");
const group: MessageGroup = {
kind: "group",
key: "tool-group",
role: "tool",
messages: [
{
key: "tool-message-1",
message: {
role: "toolResult",
toolCallId: "call-1",
toolName: "read_file",
content: "File one",
timestamp: 1000,
},
},
{
key: "tool-message-2",
message: {
role: "toolResult",
toolCallId: "call-2",
toolName: "run_command",
content: "Command output",
timestamp: 1001,
},
},
],
timestamp: 1000,
isStreaming: false,
};
renderMessageGroups(container, [group], {
isToolMessageExpanded: (id) => (id === "activity:tool-group" ? false : undefined),
});
const activity = expectElement(container, ".chat-activity-group__summary", HTMLButtonElement);
expect(activity.textContent).toContain("Activity: 2 tools");
expect(activity.textContent).toContain("read_file");
expect(activity.textContent).toContain("run_command");
expect(container.querySelector(".chat-tool-msg-body")).toBeNull();
});
it("passes the effective default-expanded activity state to the toggle handler", () => {
const container = document.createElement("div");
const onToggleToolMessageExpanded = vi.fn();
const group: MessageGroup = {
kind: "group",
key: "tool-group",
role: "tool",
messages: [
{
key: "tool-message-1",
message: {
role: "toolResult",
toolCallId: "call-1",
toolName: "read_file",
isError: true,
content: JSON.stringify({ error: "Read failed" }),
timestamp: 1000,
},
},
{
key: "tool-message-2",
message: {
role: "toolResult",
toolCallId: "call-2",
toolName: "run_command",
content: "Command output",
timestamp: 1001,
},
},
],
timestamp: 1000,
isStreaming: false,
};
renderMessageGroups(container, [group], { onToggleToolMessageExpanded });
expect(container.querySelector(".chat-activity-group.is-open")).toBeInstanceOf(HTMLElement);
expectElement(container, ".chat-activity-group__summary", HTMLButtonElement).click();
expect(onToggleToolMessageExpanded).toHaveBeenCalledWith("activity:tool-group", true);
});
it("hides grouped tool activity when tool calls are disabled", () => {
const container = document.createElement("div");
const group: MessageGroup = {
kind: "group",
key: "tool-group",
role: "tool",
messages: [
{
key: "tool-message-1",
message: {
role: "toolResult",
toolCallId: "call-1",
toolName: "read_file",
content: "File one",
timestamp: 1000,
},
},
{
key: "tool-message-2",
message: {
role: "toolResult",
toolCallId: "call-2",
toolName: "run_command",
content: "Command output",
timestamp: 1001,
},
},
],
timestamp: 1000,
isStreaming: false,
};
renderMessageGroups(container, [group], { showToolCalls: false });
expect(container.querySelector(".chat-activity-group")).toBeNull();
});
it("keeps inline tool cards collapsed by default and renders expanded state", () => {
const container = document.createElement("div");
const message = {
@@ -1029,41 +1148,6 @@ describe("grouped chat rendering", () => {
);
});
it("keeps live tool stream display labels primary for action-based tool calls", () => {
const container = document.createElement("div");
const message = {
id: "assistant-live-tool-stream",
role: "assistant",
toolCallId: "call-live-tool-stream",
content: [
{
type: "toolcall",
id: "call-live-tool-stream",
name: "skill_workshop",
arguments: { action: "create" },
},
{
type: "toolresult",
id: "call-live-tool-stream",
name: "skill_workshop",
text: "Created pending skill proposal.",
},
],
timestamp: Date.now(),
};
renderAssistantMessage(container, message, {
isToolMessageExpanded: () => false,
});
const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe(
"Skill Workshop",
);
expect(summary.querySelector(".chat-tool-msg-summary__names")?.textContent).toBe("create");
expect(summary.textContent).not.toContain("output");
});
it("renders expanded tool output rows and their json content", () => {
const container = document.createElement("div");
renderMessageGroups(

View File

@@ -32,7 +32,7 @@ import {
renderRawOutputToggle,
renderToolCard,
renderToolPreview,
resolveCollapsedToolSummaryParts,
resolveCollapsedToolDetail,
} from "./tool-cards.ts";
type AssistantAttachmentAvailability =
@@ -387,8 +387,8 @@ export function renderMessageGroup(
showReasoning: boolean;
showToolCalls?: boolean;
autoExpandToolCalls?: boolean;
isToolMessageExpanded?: (messageId: string) => boolean;
onToggleToolMessageExpanded?: (messageId: string) => void;
isToolMessageExpanded?: (messageId: string) => boolean | undefined;
onToggleToolMessageExpanded?: (messageId: string, expanded?: boolean) => void;
isToolExpanded?: (toolCardId: string) => boolean;
onToggleToolExpanded?: (toolCardId: string) => void;
onRequestUpdate?: () => void;
@@ -433,6 +433,119 @@ export function renderMessageGroup(
// Aggregate usage/cost/model across all messages in the group
const meta = extractGroupMeta(group, opts.contextWindow ?? null);
if (normalizedRole === "tool" && opts.showToolCalls === false) {
return nothing;
}
if (normalizedRole === "tool" && group.messages.length > 1) {
const cards = group.messages.flatMap((item) => extractToolCardsCached(item.message, item.key));
const toolCount = cards.length || group.messages.length;
const toolLabels = [
...new Set(
cards.map(
(card) =>
resolveToolDisplay({
name: card.name,
args: card.args,
detailMode: "explain",
}).label,
),
),
];
const preview =
toolLabels.length === 0
? "Tool output"
: toolLabels.length <= 3
? toolLabels.join(", ")
: `${toolLabels.slice(0, 2).join(", ")} +${toolLabels.length - 2} more`;
const hasError = cards.some(isToolCardError);
const activityDisclosureId = `activity:${group.key}`;
const activityExpanded = opts.isToolMessageExpanded?.(activityDisclosureId) ?? hasError;
return html`
<div class="chat-group tool chat-group--activity">
${renderChatAvatar(
group.role,
{
name: assistantName,
avatar: opts.assistantAvatar ?? null,
},
{
name: opts.userName ?? null,
avatar: opts.userAvatar ?? null,
},
opts.basePath,
opts.assistantAttachmentAuthToken,
)}
<div class="chat-group-messages">
<div class="chat-activity-group ${activityExpanded ? "is-open" : ""}">
<button
class="chat-activity-group__summary ${hasError
? "chat-activity-group__summary--error"
: ""}"
type="button"
aria-expanded=${String(activityExpanded)}
@click=${() =>
opts.onToggleToolMessageExpanded?.(activityDisclosureId, activityExpanded)}
>
<span class="chat-activity-group__icon">${icons.activity}</span>
<span class="chat-activity-group__label"
>Activity: ${toolCount} tool${toolCount === 1 ? "" : "s"}</span
>
<span class="chat-activity-group__preview">${preview}</span>
${hasError
? html`<span class="chat-activity-group__badge">${icons.x}<span>Error</span></span>`
: nothing}
<span
class="collapse-chevron ${activityExpanded ? "" : "collapse-chevron--collapsed"}"
aria-hidden="true"
>${icons.chevronDown}</span
>
</button>
${activityExpanded
? html`
<div class="chat-activity-group__body">
${group.messages.map((item, index) =>
renderGroupedMessage(
item.message,
item.key,
{
isStreaming: group.isStreaming && index === group.messages.length - 1,
sessionKey: opts.sessionKey,
agentId: opts.agentId,
duplicateCount: item.duplicateCount ?? 1,
showReasoning: opts.showReasoning,
showToolCalls: opts.showToolCalls ?? true,
autoExpandToolCalls: opts.autoExpandToolCalls ?? false,
isToolMessageExpanded: opts.isToolMessageExpanded,
onToggleToolMessageExpanded: opts.onToggleToolMessageExpanded,
isToolExpanded: opts.isToolExpanded,
onToggleToolExpanded: opts.onToggleToolExpanded,
onRequestUpdate: opts.onRequestUpdate,
canvasPluginSurfaceUrl: opts.canvasPluginSurfaceUrl,
basePath: opts.basePath,
localMediaPreviewRoots: opts.localMediaPreviewRoots,
assistantAttachmentAuthToken: opts.assistantAttachmentAuthToken,
embedSandboxMode: opts.embedSandboxMode,
allowExternalEmbedUrls: opts.allowExternalEmbedUrls,
},
opts.onOpenSidebar,
),
)}
</div>
`
: nothing}
</div>
<div class="chat-group-footer">
<span class="chat-sender-name">Activity</span>
${renderChatTimestamp(group.timestamp)}
${opts.onDelete ? renderDeleteButton(opts.onDelete, "right") : nothing}
</div>
</div>
</div>
`;
}
return html`
<div class="chat-group ${roleClass}">
${renderChatAvatar(
@@ -471,6 +584,7 @@ export function renderMessageGroup(
localMediaPreviewRoots: opts.localMediaPreviewRoots,
assistantAttachmentAuthToken: opts.assistantAttachmentAuthToken,
embedSandboxMode: opts.embedSandboxMode,
allowExternalEmbedUrls: opts.allowExternalEmbedUrls,
},
opts.onOpenSidebar,
),
@@ -1475,8 +1589,8 @@ function renderGroupedMessage(
showReasoning: boolean;
showToolCalls?: boolean;
autoExpandToolCalls?: boolean;
isToolMessageExpanded?: (messageId: string) => boolean;
onToggleToolMessageExpanded?: (messageId: string) => void;
isToolMessageExpanded?: (messageId: string) => boolean | undefined;
onToggleToolMessageExpanded?: (messageId: string, expanded?: boolean) => void;
isToolExpanded?: (toolCardId: string) => boolean;
onToggleToolExpanded?: (toolCardId: string) => void;
onRequestUpdate?: () => void;
@@ -1591,25 +1705,20 @@ function renderGroupedMessage(
detailMode: "explain",
})
: null;
const singleToolSummary =
singleToolCard &&
singleToolDisplay &&
(singleToolCard.args !== undefined || singleToolCard.inputText?.trim())
? resolveCollapsedToolSummaryParts({
card: singleToolCard,
displayLabel: singleToolDisplay.label,
displayDetail: singleToolDisplay.detail,
isError: toolMessageHasError,
})
: null;
const singleToolDisplayDetail =
!toolMessageHasError && singleToolCard && singleToolDisplay
? resolveCollapsedToolDetail(singleToolCard, singleToolDisplay.detail)
: undefined;
const toolSummaryLabelRaw = toolMessageHasError
? singleToolDisplay
? singleToolDisplay.label
: toolNames.length <= 3
? toolNames.join(", ")
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`
: singleToolSummary
? singleToolSummary.name
: singleToolDisplayDetail
? singleToolCard?.outputText?.trim()
? "output"
: undefined
: toolNames.length <= 3
? toolNames.join(", ")
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
@@ -1618,8 +1727,8 @@ function renderGroupedMessage(
markdown && !toolSummaryLabel ? (formatCollapsedToolPreviewText(markdown) ?? "") : "";
const toolMessageLabelRaw = toolMessageHasError
? "Tool error"
: singleToolSummary && !markdown && !hasImages
? singleToolSummary.label
: singleToolDisplayDetail && !markdown && !hasImages
? singleToolDisplayDetail
: singleToolDisplay && !markdown && !hasImages
? singleToolDisplay.label
: "Tool output";

View File

@@ -14,14 +14,15 @@ export type ChatRunControlsProps = {
onNewSession: () => void;
onSend: () => void;
onStoreDraft: (draft: string) => void;
showSecondary?: boolean;
};
export function renderChatRunControls(props: ChatRunControlsProps) {
const showSecondary = props.showSecondary ?? true;
return html`
<div class="agent-chat__toolbar-right">
${props.canAbort
? nothing
: html`
${showSecondary && !props.canAbort
? html`
<button
class="btn btn--ghost"
@click=${props.onNewSession}
@@ -31,18 +32,22 @@ export function renderChatRunControls(props: ChatRunControlsProps) {
${icons.plus}
<span class="agent-chat__control-label">${t("chat.runControls.newSession")}</span>
</button>
`}
<button
class="btn btn--ghost"
@click=${props.onExport}
title=${t("chat.runControls.export")}
aria-label=${t("chat.runControls.exportChat")}
?disabled=${!props.hasMessages}
>
${icons.download}
<span class="agent-chat__control-label">${t("chat.runControls.export")}</span>
</button>
`
: nothing}
${showSecondary
? html`
<button
class="btn btn--ghost"
@click=${props.onExport}
title=${t("chat.runControls.export")}
aria-label=${t("chat.runControls.exportChat")}
?disabled=${!props.hasMessages}
>
${icons.download}
<span class="agent-chat__control-label">${t("chat.runControls.export")}</span>
</button>
`
: nothing}
${props.canAbort
? html`
<button

View File

@@ -1,8 +1,11 @@
import { html } from "lit";
import { live } from "lit/directives/live.js";
import { repeat } from "lit/directives/repeat.js";
import { t } from "../../i18n/index.ts";
import { createChatSessionsLoadOverrides, scopedAgentListParamsForSession } from "../app-chat.ts";
import {
createChatSessionsLoadOverrides,
scopedAgentListParamsForSession,
scopedAgentParamsForSession,
} from "../app-chat.ts";
import type { AppViewState } from "../app-view-state.ts";
import { createChatModelOverride } from "../chat-model-ref.ts";
import {
@@ -40,7 +43,7 @@ import {
import type { GatewayThinkingLevelOption, SessionsListResult } from "../types.ts";
type ChatSessionSwitchHandler = (state: AppViewState, nextSessionKey: string) => void;
type ChatSessionSelectSurface = "desktop" | "mobile";
type ChatSessionSelectSurface = "desktop" | "mobile" | "sidebar";
type ChatSessionPickerSearchController = {
activeRequestId: number | null;
activeRequestSignature: string | null;
@@ -48,6 +51,20 @@ type ChatSessionPickerSearchController = {
timer: ReturnType<typeof globalThis.setTimeout> | null;
};
type ChatInlineSelectOption = {
value: string;
label: string;
};
const FAST_MODE_PROVIDER_IDS = new Set([
"anthropic",
"minimax",
"minimax-portal",
"openai",
"openrouter",
"xai",
]);
const CHAT_SESSION_PICKER_SEARCH_DEBOUNCE_MS = 300;
const chatSessionPickerSearchControllers = new WeakMap<
AppViewState,
@@ -62,23 +79,30 @@ function setChatError(state: AppViewState, error: string | null) {
export function renderChatSessionSelect(
state: AppViewState,
onSwitchSession: ChatSessionSwitchHandler = () => undefined,
options: { surface?: ChatSessionSelectSurface } = {},
options: {
compact?: boolean;
sessionSwitcherOnly?: boolean;
surface?: ChatSessionSelectSurface;
} = {},
) {
rememberChatAgentSessionRows(state, state.sessionsResult);
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
const agentOptions = resolveChatAgentFilterOptions(state);
const hasAgentSelect = agentOptions.length > 1;
const agentSelect = renderChatAgentSelect(state, onSwitchSession, agentOptions);
const modelSelect = renderChatModelSelect(state);
const thinkingSelect = renderChatThinkingSelect(state);
const quotaPill = renderChatQuotaPill(state);
const compact = options.compact ?? false;
const agentSelect = compact ? "" : renderChatAgentSelect(state, onSwitchSession, agentOptions);
const sessionSwitcherOnly = options.sessionSwitcherOnly ?? false;
const modelSelect = sessionSwitcherOnly ? "" : renderChatModelSelect(state);
const quotaPill = sessionSwitcherOnly ? "" : renderChatQuotaPill(state);
const surface = options.surface ?? "desktop";
const selectedSessionLabel = resolveSelectedChatSessionLabel(state, sessionGroups);
const pickerOpen = state.chatSessionPickerOpen && state.chatSessionPickerSurface === surface;
const flashSession = state.sessionSwitchFlashKey === state.sessionKey;
const rowClass = [
"chat-controls__session-row",
hasAgentSelect ? "" : "chat-controls__session-row--single-agent",
sessionSwitcherOnly ? "chat-controls__session-row--session-switcher" : "",
hasAgentSelect && !compact ? "" : "chat-controls__session-row--single-agent",
compact ? "chat-controls__session-row--compact" : "",
quotaPill ? "chat-controls__session-row--has-quota" : "",
flashSession ? "chat-controls__session-row--flash" : "",
]
@@ -94,8 +118,9 @@ export function renderChatSessionSelect(
selectedSessionLabel,
pickerOpen,
disabled: !state.connected || !state.client,
compact,
})}
${modelSelect} ${thinkingSelect} ${quotaPill}
${modelSelect} ${quotaPill}
</div>
<div class="chat-controls__session-notice" role="status" aria-live="polite">
${state.sessionSwitchNotice?.text ?? ""}
@@ -519,8 +544,10 @@ function renderChatSessionPicker(params: {
selectedSessionLabel: string;
pickerOpen: boolean;
disabled: boolean;
compact: boolean;
}) {
const { state, onSwitchSession, surface, selectedSessionLabel, pickerOpen, disabled } = params;
const { state, onSwitchSession, surface, selectedSessionLabel, pickerOpen, disabled, compact } =
params;
const pickerId = `chat-session-picker-${surface}`;
return html`
<div class="chat-controls__session chat-controls__session-picker">
@@ -542,6 +569,11 @@ function renderChatSessionPicker(params: {
}
}}
>
${compact
? html`<span class="chat-controls__session-trigger-compact-icon" aria-hidden="true">
${icons.messageSquare}
</span>`
: ""}
<span class="chat-controls__session-trigger-label">${selectedSessionLabel}</span>
<span class="chat-controls__session-trigger-icon" aria-hidden="true">
${icons.chevronDown}
@@ -799,8 +831,10 @@ async function refreshVisibleToolsEffectiveForCurrentSessionLazy(state: AppViewS
return refreshVisibleToolsEffectiveForCurrentSession(state);
}
function renderChatModelSelect(state: AppViewState) {
export function renderChatModelSelect(state: AppViewState) {
const { currentOverride, defaultLabel, options } = resolveChatModelSelectState(state);
const thinking = resolveChatThinkingSelectState(state);
const fastMode = resolveChatFastModeSelectState(state, currentOverride);
const busy =
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
const disabled =
@@ -809,35 +843,35 @@ function renderChatModelSelect(state: AppViewState) {
Boolean(state.chatModelSwitchPromises?.[state.sessionKey]) ||
(state.chatModelsLoading && options.length === 0) ||
!state.client;
const thinkingDisabled =
!state.connected ||
busy ||
!state.client ||
(thinking.options.length === 0 && thinking.currentOverride === "");
const selectedLabel =
currentOverride === ""
? defaultLabel
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
return html`
<label class="field chat-controls__session chat-controls__model">
<select
data-chat-model-select="true"
aria-label=${t("chat.selectors.model")}
title=${selectedLabel}
.value=${live(currentOverride)}
?disabled=${disabled}
@change=${async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
await switchChatModel(state, next);
}}
>
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
${repeat(
options,
(entry) => entry.value,
(entry) =>
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
${entry.label}
</option>`,
)}
</select>
</label>
`;
const selectedThinkingLabel =
thinking.currentOverride === ""
? thinking.defaultLabel
: (thinking.options.find((entry) => entry.value === thinking.currentOverride)?.label ??
thinking.currentOverride);
const modelOptions = [{ value: "", label: defaultLabel }, ...options];
return renderChatModelReasoningSelect({
disabled,
modelOptions,
selectedModelLabel: selectedLabel,
selectedModelValue: currentOverride,
selectedThinkingLabel,
selectedThinkingValue: thinking.currentOverride,
fastMode,
thinkingDisabled,
thinkingOptions: [{ value: "", label: thinking.defaultLabel }, ...thinking.options],
onModelSelect: (next) => switchChatModel(state, next),
onFastModeSelect: (next) => switchChatFastMode(state, next),
onThinkingSelect: (next) => switchChatThinkingLevel(state, next),
});
}
type ChatThinkingSelectOption = {
@@ -851,6 +885,13 @@ type ChatThinkingSelectState = {
options: ChatThinkingSelectOption[];
};
type ChatFastModeSelectState = {
currentOverride: "" | "on" | "off";
disabled: boolean;
options: ChatInlineSelectOption[];
supported: boolean;
};
function resolveThinkingTargetModel(state: AppViewState): {
provider: string | null;
model: string | null;
@@ -862,6 +903,60 @@ function resolveThinkingTargetModel(state: AppViewState): {
};
}
function resolveProviderFromModelValue(
value: string,
catalog: AppViewState["chatModelCatalog"],
): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const separator = trimmed.indexOf("/");
if (separator > 0) {
return trimmed.slice(0, separator).toLowerCase();
}
return (
catalog
.find((entry) => entry.id.trim().toLowerCase() === trimmed.toLowerCase())
?.provider.trim()
.toLowerCase() || null
);
}
function resolveChatFastModeSelectState(
state: AppViewState,
currentModelOverride: string,
): ChatFastModeSelectState {
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
const { provider } = resolveThinkingTargetModel(state);
const effectiveProvider =
resolveProviderFromModelValue(currentModelOverride, state.chatModelCatalog ?? []) ??
provider?.trim().toLowerCase() ??
null;
const currentOverride =
activeRow?.fastMode === true ? "on" : activeRow?.fastMode === false ? "off" : "";
const supported = Boolean(
(effectiveProvider && FAST_MODE_PROVIDER_IDS.has(effectiveProvider)) || currentOverride,
);
return {
currentOverride,
disabled:
!supported ||
!state.connected ||
state.chatLoading ||
state.chatSending ||
Boolean(state.chatRunId) ||
state.chatStream !== null ||
!state.client,
options: [
{ value: "", label: "Default" },
{ value: "on", label: "Fast" },
{ value: "off", label: "Standard" },
],
supported,
};
}
function sessionModelMatchesDefaults(
row: SessionsListResult["sessions"][number] | undefined,
defaults: SessionsListResult["defaults"] | undefined,
@@ -985,44 +1080,275 @@ export function resolveChatThinkingSelectState(state: AppViewState): ChatThinkin
};
}
export function renderChatThinkingSelect(state: AppViewState) {
const { currentOverride, defaultLabel, options } = resolveChatThinkingSelectState(state);
const busy =
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
const disabled =
!state.connected || busy || !state.client || (options.length === 0 && currentOverride === "");
const selectedLabel =
currentOverride === ""
? defaultLabel
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
const onChange = async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
await switchChatThinkingLevel(state, next);
};
function formatCombinedPickerModelLabel(label: string): string {
const match = /^Default \((.+)\)$/u.exec(label);
return match?.[1] ?? label;
}
function formatCombinedPickerModelOptionLabel(
option: ChatInlineSelectOption,
selected: boolean,
): string {
return option.value === "" && selected
? formatCombinedPickerModelLabel(option.label)
: option.label;
}
function formatCombinedPickerThinkingLabel(label: string): string {
return label.replace(/^Inherited:\s*/u, "");
}
function formatCombinedPickerThinkingOptionLabel(option: ChatInlineSelectOption): string {
return option.value === "" ? "Default" : formatCombinedPickerThinkingLabel(option.label);
}
function renderChatModelReasoningSelect(params: {
fastMode: ChatFastModeSelectState;
disabled: boolean;
modelOptions: ChatInlineSelectOption[];
selectedModelLabel: string;
selectedModelValue: string;
selectedThinkingLabel: string;
selectedThinkingValue: string;
thinkingDisabled: boolean;
thinkingOptions: ChatInlineSelectOption[];
onFastModeSelect: (value: "" | "on" | "off") => Promise<unknown>;
onModelSelect: (value: string) => Promise<unknown>;
onThinkingSelect: (value: string) => Promise<unknown>;
}) {
const {
disabled,
fastMode,
modelOptions,
selectedModelLabel,
selectedModelValue,
selectedThinkingLabel,
selectedThinkingValue,
thinkingDisabled,
thinkingOptions,
onFastModeSelect,
onModelSelect,
onThinkingSelect,
} = params;
const triggerModel = formatCombinedPickerModelLabel(selectedModelLabel);
const triggerThinking = formatCombinedPickerThinkingLabel(selectedThinkingLabel);
const triggerLabel = `${triggerModel} · ${triggerThinking}`;
return html`
<label class="field chat-controls__session chat-controls__thinking-select">
<select
class="chat-controls__thinking-select-full"
<details class="chat-controls__session chat-controls__inline-select chat-controls__model">
<summary
class="chat-controls__inline-select-trigger ${disabled
? "chat-controls__inline-select-trigger--disabled"
: ""}"
data-chat-model-select="true"
data-chat-thinking-select="true"
aria-label=${t("chat.selectors.thinkingLevel")}
title=${selectedLabel}
?disabled=${disabled}
@change=${onChange}
data-chat-select-value=${selectedModelValue}
data-chat-thinking-value=${selectedThinkingValue}
data-chat-thinking-disabled=${thinkingDisabled ? "true" : "false"}
aria-label=${`${t("chat.selectors.model")}, ${t("chat.selectors.thinkingLevel")}: ${triggerLabel}`}
aria-disabled=${disabled ? "true" : "false"}
title=${triggerLabel}
@click=${(event: MouseEvent) => {
if (disabled) {
event.preventDefault();
}
}}
>
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
${repeat(
options,
(entry) => entry.value,
(entry) =>
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
${entry.label}
</option>`,
)}
</select>
</label>
<span class="chat-controls__inline-select-label">${triggerLabel}</span>
<span class="chat-controls__inline-select-icon" aria-hidden="true">
${icons.chevronDown}
</span>
</summary>
<div
class="chat-controls__inline-select-menu chat-controls__inline-select-menu--combined"
aria-label=${t("chat.selectors.model")}
>
<div class="chat-controls__inline-select-section-label">Model</div>
<div class="chat-controls__combined-model-list">
${repeat(
modelOptions,
(entry) => entry.value,
(entry) => {
const selected = entry.value === selectedModelValue;
return html`
<div class="chat-controls__combined-model">
<button
class="chat-controls__inline-select-option chat-controls__combined-model-option ${selected
? "chat-controls__inline-select-option--selected"
: ""}"
data-chat-model-option=${entry.value}
role="option"
aria-selected=${selected ? "true" : "false"}
type="button"
?disabled=${disabled}
@click=${async (event: MouseEvent) => {
if (disabled || selected) {
event.preventDefault();
return;
}
(event.currentTarget as HTMLElement)
.closest("details")
?.removeAttribute("open");
await onModelSelect(entry.value);
}}
>
<span>${formatCombinedPickerModelOptionLabel(entry, selected)}</span>
${selected
? html`<span
class="chat-controls__inline-select-check chat-controls__combined-model-arrow"
aria-hidden="true"
>
${icons.chevronDown}
</span>`
: ""}
</button>
</div>
`;
},
)}
</div>
<div
class="chat-controls__reasoning-panel"
role="listbox"
aria-label=${t("chat.selectors.thinkingLevel")}
>
<div class="chat-controls__inline-select-section-label">Reasoning</div>
<div class="chat-controls__reasoning-options">
${repeat(
thinkingOptions,
(thinking) => thinking.value,
(thinking) => {
const thinkingSelected = thinking.value === selectedThinkingValue;
return html`
<button
class="chat-controls__reasoning-option ${thinkingSelected
? "chat-controls__reasoning-option--selected"
: ""}"
data-chat-thinking-option=${thinking.value}
role="option"
aria-selected=${thinkingSelected ? "true" : "false"}
type="button"
?disabled=${thinkingDisabled}
@click=${async (event: MouseEvent) => {
event.stopPropagation();
if (thinkingDisabled) {
event.preventDefault();
return;
}
(event.currentTarget as HTMLElement)
.closest("details")
?.removeAttribute("open");
await onThinkingSelect(thinking.value);
}}
>
<span>${formatCombinedPickerThinkingOptionLabel(thinking)}</span>
${thinkingSelected
? html`<span class="chat-controls__inline-select-check" aria-hidden="true">
${icons.check}
</span>`
: ""}
</button>
`;
},
)}
</div>
${fastMode.supported
? html`
<div class="chat-controls__inline-select-section-label">Speed</div>
<div class="chat-controls__reasoning-options" role="listbox">
${repeat(
fastMode.options,
(speed) => speed.value,
(speed) => {
const speedValue = speed.value as "" | "on" | "off";
const speedSelected = speedValue === fastMode.currentOverride;
return html`
<button
class="chat-controls__reasoning-option ${speedSelected
? "chat-controls__reasoning-option--selected"
: ""}"
data-chat-speed-option=${speed.value}
role="option"
aria-selected=${speedSelected ? "true" : "false"}
type="button"
?disabled=${fastMode.disabled}
@click=${async (event: MouseEvent) => {
event.stopPropagation();
if (fastMode.disabled) {
event.preventDefault();
return;
}
(event.currentTarget as HTMLElement)
.closest("details")
?.removeAttribute("open");
await onFastModeSelect(speedValue);
}}
>
<span>${speed.label}</span>
${speedSelected
? html`<span
class="chat-controls__inline-select-check"
aria-hidden="true"
>
${icons.check}
</span>`
: ""}
</button>
`;
},
)}
</div>
`
: ""}
</div>
</div>
</details>
`;
}
function patchSessionFastMode(
state: AppViewState,
sessionKey: string,
fastMode: boolean | undefined,
) {
const current = state.sessionsResult;
if (!current) {
return;
}
state.sessionsResult = {
...current,
sessions: current.sessions.map((row) =>
row.key === sessionKey ? Object.assign({}, row, { fastMode }) : row,
),
};
}
async function switchChatFastMode(state: AppViewState, nextFastMode: "" | "on" | "off") {
if (!state.client || !state.connected) {
return;
}
const targetSessionKey = state.sessionKey;
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === targetSessionKey);
const previousFastMode = activeRow?.fastMode;
const next = nextFastMode === "" ? undefined : nextFastMode === "on";
if (previousFastMode === next) {
return;
}
setChatError(state, null);
patchSessionFastMode(state, targetSessionKey, next);
try {
await state.client.request("sessions.patch", {
key: targetSessionKey,
...scopedAgentParamsForSession(state, targetSessionKey),
fastMode: next ?? null,
});
await refreshSessionOptions(state);
patchSessionFastMode(state, targetSessionKey, next);
} catch (err) {
patchSessionFastMode(state, targetSessionKey, previousFastMode);
setChatError(state, `Failed to set speed: ${String(err)}`);
}
}
async function switchChatModel(state: AppViewState, nextModel: string): Promise<boolean> {
if (!state.client || !state.connected) {
return false;
@@ -1116,6 +1442,8 @@ async function switchChatThinkingLevel(state: AppViewState, nextThinkingLevel: s
thinkingLevel: normalizedNext ?? null,
});
await refreshSessionOptions(state);
patchSessionThinkingLevel(state, targetSessionKey, normalizedNext);
state.chatThinkingLevel = normalizedNext ?? null;
} catch (err) {
patchSessionThinkingLevel(state, targetSessionKey, previousThinkingLevel);
state.chatThinkingLevel = normalizedPrev ?? null;

View File

@@ -34,15 +34,7 @@ export type SlashCommandResult = {
/** Markdown-formatted result to display in chat. */
content: string;
/** Side-effect action the caller should perform after displaying the result. */
action?:
| "refresh"
| "export"
| "new-session"
| "reset"
| "stop"
| "clear"
| "toggle-focus"
| "navigate-usage";
action?: "refresh" | "export" | "new-session" | "reset" | "stop" | "clear" | "navigate-usage";
/** Optional session-level directive changes that the caller should mirror locally. */
sessionPatch?: {
modelOverride?: ChatModelOverride | null;
@@ -103,8 +95,6 @@ export async function executeSlashCommand(
return { content: "Stopping current run...", action: "stop" };
case "clear":
return { content: "Chat history cleared.", action: "clear" };
case "focus":
return { content: "Toggled focus mode.", action: "toggle-focus" };
case "compact":
return await executeCompact(client, sessionKey, context);
case "model":

View File

@@ -130,10 +130,6 @@ describe("parseSlashCommand", () => {
expect(requireArray(steer.aliases, "steer aliases")).toEqual(["tell"]);
});
it("keeps focus as a local slash command", () => {
expectParsedSlash("/focus", { key: "focus", executeLocal: true }, "");
});
it("refreshes runtime commands from commands.list so docks, plugins, and direct skills appear", async () => {
const request = async (method: string) => {
expect(method).toBe("commands.list");

View File

@@ -68,8 +68,6 @@ const COMMAND_ICON_OVERRIDES: Partial<Record<string, IconName>> = {
compact: "loader",
stop: "stop",
clear: "trash",
focus: "eye",
unfocus: "eye",
model: "brain",
models: "brain",
think: "brain",
@@ -87,7 +85,6 @@ const LOCAL_COMMANDS = new Set([
"reset",
"stop",
"compact",
"focus",
"model",
"think",
"fast",
@@ -139,8 +136,6 @@ const CATEGORY_OVERRIDES: Partial<Record<string, SlashCommandCategory>> = {
reset: "session",
new: "session",
compact: "session",
focus: "session",
unfocus: "session",
model: "model",
models: "model",
think: "model",

View File

@@ -1,223 +0,0 @@
import { LitElement, css, html, nothing } from "lit";
import { property, query, state } from "lit/decorators.js";
import { styleMap } from "lit/directives/style-map.js";
export type TooltipPlacement = "top" | "bottom";
export type TooltipAlign = "start" | "center" | "end";
type TooltipPosition = {
top: number;
left: number;
arrowLeft: number;
placement: TooltipPlacement;
};
const TOOLTIP_GAP = 10;
const TOOLTIP_MARGIN = 8;
const TOOLTIP_ARROW_MIN = 14;
export class OpenClawTooltip extends LitElement {
@property() text = "";
@property({ reflect: true }) placement: TooltipPlacement = "bottom";
@property({ reflect: true }) align: TooltipAlign = "center";
@state() private open = false;
@state() private position: TooltipPosition | null = null;
@query(".trigger") private triggerElement?: HTMLElement;
@query(".tooltip") private tooltipElement?: HTMLElement;
static override styles = css`
:host {
display: inline-flex;
min-width: 0;
}
.trigger {
display: inline-flex;
min-width: 0;
}
.wrap {
display: inline-flex;
min-width: 0;
}
.tooltip {
position: fixed;
top: 0;
left: 0;
z-index: 400;
box-sizing: border-box;
width: min(260px, calc(100vw - 16px));
padding: 9px 11px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--popover, var(--bg-elevated));
color: var(--popover-foreground, var(--text-strong));
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.42);
font-family:
var(--font-sans),
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
text-align: left;
white-space: normal;
pointer-events: none;
transform: translateY(0);
transition:
opacity 120ms ease,
transform 120ms ease;
}
.tooltip::before {
content: "";
position: absolute;
width: 8px;
height: 8px;
background: inherit;
border: inherit;
transform: rotate(45deg);
left: var(--tooltip-arrow-left, 50%);
}
.tooltip[data-placement="bottom"]::before {
top: -5px;
border-right: 0;
border-bottom: 0;
}
.tooltip[data-placement="top"]::before {
bottom: -5px;
border-left: 0;
border-top: 0;
}
@media (prefers-reduced-motion: reduce) {
.tooltip {
transition: none;
}
}
`;
override render() {
const text = this.text.trim();
const position = this.position;
const tooltipStyle = position
? {
left: `${position.left}px`,
top: `${position.top}px`,
"--tooltip-arrow-left": `${position.arrowLeft}px`,
}
: {
left: "0px",
top: "0px",
visibility: "hidden",
};
return html`
<span
class="wrap"
@pointerenter=${this.openTooltip}
@pointerleave=${this.closeTooltip}
@focusin=${this.openTooltip}
@focusout=${this.closeTooltip}
>
<span class="trigger"><slot></slot></span>
${this.open && text
? html`
<span
class="tooltip"
role="tooltip"
data-placement=${position?.placement ?? this.placement}
style=${styleMap(tooltipStyle)}
>${text}</span
>
`
: nothing}
</span>
`;
}
private openTooltip = async () => {
if (!this.text.trim()) {
return;
}
this.open = true;
this.position = null;
await this.updateComplete;
this.placeTooltip();
};
private closeTooltip = () => {
this.open = false;
this.position = null;
};
private placeTooltip() {
const trigger = this.triggerElement;
const tooltip = this.tooltipElement;
if (!trigger || !tooltip) {
return;
}
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const preferredPlacement = this.resolvePlacement(triggerRect, tooltipRect, viewportHeight);
const unclampedLeft = this.resolveLeft(triggerRect, tooltipRect);
const maxLeft = Math.max(TOOLTIP_MARGIN, viewportWidth - tooltipRect.width - TOOLTIP_MARGIN);
const left = clamp(unclampedLeft, TOOLTIP_MARGIN, maxLeft);
const rawTop =
preferredPlacement === "bottom"
? triggerRect.bottom + TOOLTIP_GAP
: triggerRect.top - tooltipRect.height - TOOLTIP_GAP;
const maxTop = Math.max(TOOLTIP_MARGIN, viewportHeight - tooltipRect.height - TOOLTIP_MARGIN);
const top = clamp(rawTop, TOOLTIP_MARGIN, maxTop);
const triggerCenter = triggerRect.left + triggerRect.width / 2;
const arrowMax = Math.max(TOOLTIP_ARROW_MIN, tooltipRect.width - TOOLTIP_ARROW_MIN);
this.position = {
left: Math.round(left),
top: Math.round(top),
arrowLeft: Math.round(clamp(triggerCenter - left, TOOLTIP_ARROW_MIN, arrowMax)),
placement: preferredPlacement,
};
}
private resolvePlacement(
triggerRect: DOMRect,
tooltipRect: DOMRect,
viewportHeight: number,
): TooltipPlacement {
if (this.placement === "top") {
const fitsTop = triggerRect.top - tooltipRect.height - TOOLTIP_GAP >= TOOLTIP_MARGIN;
return fitsTop ? "top" : "bottom";
}
const fitsBottom =
triggerRect.bottom + tooltipRect.height + TOOLTIP_GAP <= viewportHeight - TOOLTIP_MARGIN;
return fitsBottom ? "bottom" : "top";
}
private resolveLeft(triggerRect: DOMRect, tooltipRect: DOMRect): number {
if (this.align === "start") {
return triggerRect.left;
}
if (this.align === "end") {
return triggerRect.right - tooltipRect.width;
}
return triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
}
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
if (!customElements.get("openclaw-tooltip")) {
customElements.define("openclaw-tooltip", OpenClawTooltip);
}

View File

@@ -60,12 +60,12 @@ export async function loadAgentFileContent(
agentId: string,
name: string,
opts?: { force?: boolean; preserveDraft?: boolean },
) {
): Promise<boolean> {
if (!state.client || !state.connected || state.agentFilesLoading) {
return;
return false;
}
if (!opts?.force && Object.hasOwn(state.agentFileContents, name)) {
return;
return true;
}
state.agentFilesLoading = true;
state.agentFilesError = null;
@@ -88,12 +88,15 @@ export async function loadAgentFileContent(
) {
state.agentFileDrafts = { ...state.agentFileDrafts, [name]: content };
}
return true;
}
} catch (err) {
state.agentFilesError = String(err);
return false;
} finally {
state.agentFilesLoading = false;
}
return false;
}
export async function saveAgentFile(

View File

@@ -295,40 +295,51 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
await page.goto(`${server.baseUrl}chat`);
const main = page.getByRole("main");
const modelSelect = main.locator('select[data-chat-model-select="true"]');
await modelSelect.waitFor({ state: "visible", timeout: 10_000 });
expect(await modelSelect.inputValue()).toBe("");
const openModelSelect = async () => {
const trigger = main.locator('[data-chat-model-select="true"]').first();
await trigger.waitFor({ state: "visible", timeout: 10_000 });
return trigger;
};
const selectModel = async (value: string) => {
await main.locator('[data-chat-model-select="true"]').click();
const option = main.locator(`[data-chat-model-option="${value}"]`);
await option.waitFor({ state: "visible", timeout: 10_000 });
await option.click();
};
await modelSelect.selectOption("bedrock/claude-opus-4.5");
let modelSelect = await openModelSelect();
expect(await modelSelect.getAttribute("data-chat-select-value")).toBe("");
await selectModel("bedrock/claude-opus-4.5");
const patchRequest = await gateway.waitForRequest("sessions.patch");
expect(requireRecord(patchRequest.params)).toMatchObject({
key: "agent:main:session-a",
model: "bedrock/claude-opus-4.5",
});
expect(await modelSelect.inputValue()).toBe("bedrock/claude-opus-4.5");
expect(await modelSelect.getAttribute("data-chat-select-value")).toBe(
"bedrock/claude-opus-4.5",
);
await main.getByRole("button", { name: "Chat session" }).click();
await page
.locator(
'button[data-chat-session-picker-option="true"][data-session-key="agent:main:session-b"]',
)
.locator('a.sidebar-recent-session[data-session-key="agent:main:session-b"]')
.click();
await main.getByRole("button", { name: "Chat session" }).getByText("Session B").waitFor({
await page.locator(".sidebar-recent-session--active").getByText("Session B").waitFor({
timeout: 10_000,
});
expect(await modelSelect.inputValue()).toBe("");
modelSelect = await openModelSelect();
expect(await modelSelect.getAttribute("data-chat-select-value")).toBe("");
await main.getByRole("button", { name: "Chat session" }).click();
await page
.locator(
'button[data-chat-session-picker-option="true"][data-session-key="agent:main:session-a"]',
)
.locator('a.sidebar-recent-session[data-session-key="agent:main:session-a"]')
.click();
await main.getByRole("button", { name: "Chat session" }).getByText("Session A").waitFor({
await page.locator(".sidebar-recent-session--active").getByText("Session A").waitFor({
timeout: 10_000,
});
expect(await modelSelect.inputValue()).toBe("bedrock/claude-opus-4.5");
modelSelect = await openModelSelect();
expect(await modelSelect.getAttribute("data-chat-select-value")).toBe(
"bedrock/claude-opus-4.5",
);
} finally {
await context.close();
}

View File

@@ -564,12 +564,12 @@ describe("control UI routing", () => {
});
});
it("closes mobile chat controls on Escape, outside pointerdown, and tab changes", async () => {
it("closes composer view settings on Escape, outside pointerdown, and tab changes", async () => {
const app = mountApp("/chat");
await app.updateComplete;
const toggle = expectElement(app, ".chat-controls-mobile-toggle", HTMLButtonElement);
const dropdown = expectElement(app, ".chat-controls-dropdown", HTMLElement);
const toggle = expectElement(app, ".chat-settings-chip", HTMLButtonElement);
const dropdown = expectElement(app, ".chat-settings-popover", HTMLElement);
toggle.focus();
toggle.click();
@@ -577,13 +577,11 @@ describe("control UI routing", () => {
expect(app.chatMobileControlsOpen).toBe(true);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect([...toggle.classList]).toEqual([
"btn",
"btn--sm",
"btn--icon",
"chat-controls-mobile-toggle",
expect([...toggle.classList]).toEqual(["chat-settings-chip", "chat-settings-chip--open"]);
expect([...dropdown.classList]).toEqual([
"chat-settings-popover",
"chat-settings-popover--open",
]);
expect([...dropdown.classList]).toEqual(["chat-controls-dropdown", "open"]);
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await app.updateComplete;
@@ -591,7 +589,7 @@ describe("control UI routing", () => {
expect(app.chatMobileControlsOpen).toBe(false);
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect([...dropdown.classList]).toEqual(["chat-controls-dropdown"]);
expect([...dropdown.classList]).toEqual(["chat-settings-popover"]);
expect(document.activeElement).toBe(toggle);
toggle.click();
@@ -599,18 +597,21 @@ describe("control UI routing", () => {
app.requestUpdate();
await app.updateComplete;
const openDropdown = expectElement(app, ".chat-controls-dropdown", HTMLElement);
const openDropdown = expectElement(app, ".chat-settings-popover", HTMLElement);
expect(app.chatMobileControlsOpen).toBe(true);
expect([...openDropdown.classList]).toEqual(["chat-controls-dropdown", "open"]);
expect([...openDropdown.classList]).toEqual([
"chat-settings-popover",
"chat-settings-popover--open",
]);
document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true }));
await app.updateComplete;
const closedDropdown = expectElement(app, ".chat-controls-dropdown", HTMLElement);
const closedDropdown = expectElement(app, ".chat-settings-popover", HTMLElement);
expect(app.chatMobileControlsOpen).toBe(false);
expect([...closedDropdown.classList]).toEqual(["chat-controls-dropdown"]);
expect([...closedDropdown.classList]).toEqual(["chat-settings-popover"]);
expectElement(app, ".chat-controls-mobile-toggle", HTMLButtonElement).click();
expectElement(app, ".chat-settings-chip", HTMLButtonElement).click();
await app.updateComplete;
expect(app.chatMobileControlsOpen).toBe(true);
@@ -619,7 +620,7 @@ describe("control UI routing", () => {
expect(app.chatMobileControlsOpen).toBe(false);
});
it("preserves session navigation and keeps focus mode scoped to chat", async () => {
it("preserves session navigation without hiding the page chrome", async () => {
const app = mountApp("/sessions?session=agent:main:subagent:task-123");
await app.updateComplete;
@@ -634,22 +635,14 @@ describe("control UI routing", () => {
const shell = expectElement(app, ".shell", HTMLElement);
const topbar = expectElement(app, ".topbar", HTMLElement);
const contentHeader = expectElement(app, ".content-header", HTMLElement);
const sessionSelect = expectElement(app, ".sidebar-session-select", HTMLElement);
expect([...shell.classList]).toEqual(["shell", "shell--chat"]);
expect(topbar.hasAttribute("inert")).toBe(false);
expect(topbar.hasAttribute("aria-hidden")).toBe(false);
expect(contentHeader.hasAttribute("inert")).toBe(false);
expect(contentHeader.hasAttribute("aria-hidden")).toBe(false);
const toggle = expectElement(app, 'button[title^="Toggle focus mode"]', HTMLButtonElement);
toggle.click();
await app.updateComplete;
expect([...shell.classList]).toEqual(["shell", "shell--chat", "shell--chat-focus"]);
expect(topbar.hasAttribute("inert")).toBe(true);
expect(topbar.getAttribute("aria-hidden")).toBe("true");
expect(contentHeader.hasAttribute("inert")).toBe(true);
expect(contentHeader.getAttribute("aria-hidden")).toBe("true");
expect(app.querySelector(".content-header")).toBeNull();
expect(sessionSelect.querySelector(".chat-controls__session-picker")).toBeInstanceOf(
HTMLElement,
);
app.setTab("channels");
@@ -667,12 +660,10 @@ describe("control UI routing", () => {
await app.updateComplete;
expect(app.tab).toBe("chat");
expect([...shell.classList]).toEqual(["shell", "shell--chat", "shell--chat-focus"]);
expect(topbar.hasAttribute("inert")).toBe(true);
expect(topbar.getAttribute("aria-hidden")).toBe("true");
const focusedContentHeader = expectElement(app, ".content-header", HTMLElement);
expect(focusedContentHeader.hasAttribute("inert")).toBe(true);
expect(focusedContentHeader.getAttribute("aria-hidden")).toBe("true");
expect([...shell.classList]).toEqual(["shell", "shell--chat"]);
expect(topbar.hasAttribute("inert")).toBe(false);
expect(topbar.hasAttribute("aria-hidden")).toBe(false);
expect(app.querySelector(".content-header")).toBeNull();
});
it("auto-scrolls chat history to the latest message", async () => {

View File

@@ -138,7 +138,6 @@ describe("loadSettings default gateway URL derivation", () => {
gatewayUrl: "wss://gateway.example:8443/openclaw",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
chatAutoScroll: "near-bottom",
@@ -174,7 +173,6 @@ describe("loadSettings default gateway URL derivation", () => {
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
chatAutoScroll: "near-bottom",
@@ -207,7 +205,6 @@ describe("loadSettings default gateway URL derivation", () => {
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
chatAutoScroll: "near-bottom",
@@ -225,7 +222,6 @@ describe("loadSettings default gateway URL derivation", () => {
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
chatAutoScroll: "near-bottom",
@@ -256,7 +252,6 @@ describe("loadSettings default gateway URL derivation", () => {
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
@@ -274,7 +269,6 @@ describe("loadSettings default gateway URL derivation", () => {
gatewayUrl: gwUrl,
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
chatAutoScroll: "near-bottom",
@@ -310,7 +304,6 @@ describe("loadSettings default gateway URL derivation", () => {
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
chatAutoScroll: "near-bottom",
@@ -332,7 +325,6 @@ describe("loadSettings default gateway URL derivation", () => {
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
chatAutoScroll: "near-bottom",
@@ -415,7 +407,6 @@ describe("loadSettings default gateway URL derivation", () => {
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
@@ -431,7 +422,6 @@ describe("loadSettings default gateway URL derivation", () => {
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
@@ -460,7 +450,6 @@ describe("loadSettings default gateway URL derivation", () => {
lastActiveSessionKey: "main",
theme: "dash",
themeMode: "light",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
@@ -496,7 +485,6 @@ describe("loadSettings default gateway URL derivation", () => {
lastActiveSessionKey: "main",
theme: "custom",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
@@ -527,7 +515,6 @@ describe("loadSettings default gateway URL derivation", () => {
gatewayUrl: gwUrl,
theme: "custom",
themeMode: "dark",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
@@ -572,7 +559,6 @@ describe("loadSettings default gateway URL derivation", () => {
lastActiveSessionKey: "agent:test_old:main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
@@ -616,7 +602,6 @@ describe("loadSettings default gateway URL derivation", () => {
lastActiveSessionKey: "agent:current:main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,

View File

@@ -85,7 +85,6 @@ export type UiSettings = {
lastActiveSessionKey: string;
theme: ThemeName;
themeMode: ThemeMode;
chatFocusMode: boolean;
chatShowThinking: boolean;
chatShowToolCalls: boolean;
chatAutoScroll?: ChatAutoScrollMode;
@@ -229,7 +228,6 @@ export function loadSettings(): UiSettings {
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
chatAutoScroll: "near-bottom",
@@ -269,8 +267,6 @@ export function loadSettings(): UiSettings {
lastActiveSessionKey: scopedSessionSelection.lastActiveSessionKey,
theme: theme === "custom" && !customTheme ? "claw" : theme,
themeMode: mode,
chatFocusMode:
typeof parsed.chatFocusMode === "boolean" ? parsed.chatFocusMode : defaults.chatFocusMode,
chatShowThinking:
typeof parsed.chatShowThinking === "boolean"
? parsed.chatShowThinking
@@ -418,7 +414,6 @@ function persistSettings(next: UiSettings) {
gatewayUrl: next.gatewayUrl,
theme: next.theme,
themeMode: next.themeMode,
chatFocusMode: next.chatFocusMode,
chatShowThinking: next.chatShowThinking,
chatShowToolCalls: next.chatShowToolCalls,
chatAutoScroll: normalizeChatAutoScrollMode(next.chatAutoScroll),

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { html, render } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { i18n, t } from "../../i18n/index.ts";
import { switchChatSession } from "../app-render.helpers.ts";
@@ -378,7 +378,6 @@ function createChatHeaderState(
navCollapsed: false,
navGroupsCollapsed: {},
borderRadius: 50,
chatFocusMode: false,
chatShowThinking: false,
},
chatMessage: "",
@@ -420,17 +419,52 @@ async function flushTasks() {
await vi.dynamicImportSettled();
}
function getChatModelSelect(container: Element): HTMLSelectElement {
const select = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(select).toBeInstanceOf(HTMLSelectElement);
if (!(select instanceof HTMLSelectElement)) {
throw new Error("Expected chat model select");
function getChatModelSelect(container: Element): HTMLElement {
const select = container.querySelector<HTMLElement>('[data-chat-model-select="true"]');
expect(select).toBeInstanceOf(HTMLElement);
if (!(select instanceof HTMLElement)) {
throw new Error("Expected chat model control");
}
return select;
}
function getChatSelectValue(control: HTMLElement): string {
return control.dataset.chatSelectValue ?? "";
}
function getChatThinkingValue(control: HTMLElement): string {
return control.dataset.chatThinkingValue ?? "";
}
function clickChatModelOption(container: Element, value: string) {
const option = Array.from(
container.querySelectorAll<HTMLButtonElement>("[data-chat-model-option]"),
).find((button) => button.dataset.chatModelOption === value);
expect(option).toBeInstanceOf(HTMLButtonElement);
option?.click();
}
function clickChatSpeedOption(container: Element, value: string) {
const option = Array.from(
container.querySelectorAll<HTMLButtonElement>("[data-chat-speed-option]"),
).find((button) => button.dataset.chatSpeedOption === value);
expect(option).toBeInstanceOf(HTMLButtonElement);
option?.click();
}
function getThinkingSelect(container: Element): HTMLElement {
const select = container.querySelector<HTMLElement>('[data-chat-thinking-select="true"]');
expect(select).toBeInstanceOf(HTMLElement);
if (!(select instanceof HTMLElement)) {
throw new Error("Expected chat thinking control");
}
return select;
}
function getThinkingOptions(container: Element): HTMLButtonElement[] {
return Array.from(container.querySelectorAll<HTMLButtonElement>("[data-chat-thinking-option]"));
}
function requireElement(container: Element, selector: string, label: string): Element {
const element = container.querySelector(selector);
if (element === null) {
@@ -470,7 +504,6 @@ function renderChatView(overrides: Partial<Parameters<typeof renderChat>[0]> = {
disabledReason: null,
error: null,
sessions: null,
focusMode: false,
sidebarOpen: false,
sidebarContent: null,
sidebarError: null,
@@ -490,7 +523,6 @@ function renderChatView(overrides: Partial<Parameters<typeof renderChat>[0]> = {
showNewMessages: false,
onScrollToBottom: () => undefined,
onRefresh: () => undefined,
onToggleFocusMode: () => undefined,
getDraft: () => "",
onDraftChange: () => undefined,
onRequestUpdate: () => undefined,
@@ -545,7 +577,7 @@ describe("chat compaction divider", () => {
});
describe("chat goal status", () => {
it("renders the active session goal above the composer", () => {
it("renders the active session goal inside the composer", () => {
const container = renderChatView({
sessions: createSessionsResultFromRows([
{
@@ -573,6 +605,51 @@ describe("chat goal status", () => {
"Pursuing goal (12k/50k) Land the web goal UI",
);
expect(goal?.getAttribute("aria-label")).toBe("Pursuing goal (12k/50k): Land the web goal UI");
expect(goal?.closest(".agent-chat__composer-status-stack")).not.toBeNull();
});
});
describe("chat composer workbench", () => {
it("renders session controls in the composer and workspace files in the rail", () => {
const onRefresh = vi.fn();
const onOpenFile = vi.fn();
const container = renderChatView({
composerControls: html`<button class="test-composer-control">Model</button>`,
workspaceFiles: {
agentId: "main",
list: {
agentId: "main",
workspace: "/workspace",
files: [
{
name: "AGENTS.md",
path: "/workspace/AGENTS.md",
missing: false,
size: 2048,
},
],
},
loading: false,
error: null,
activeName: "AGENTS.md",
onRefresh,
onOpenFile,
},
});
expect(
container.querySelector(".agent-chat__composer-controls .test-composer-control"),
).not.toBeNull();
expect(container.querySelector(".chat-workspace-rail__path")?.textContent?.trim()).toBe(
"/workspace",
);
const file = container.querySelector<HTMLButtonElement>(".chat-workspace-rail__file");
expect(file?.textContent).toContain("AGENTS.md");
expect(file?.textContent).toContain("2 KB");
file?.click();
expect(onOpenFile).toHaveBeenCalledWith("AGENTS.md");
});
});
@@ -745,7 +822,6 @@ describe("chat voice controls", () => {
const container = renderChatView();
requireElement(container, '[aria-label="Start Talk"]', "Start Talk button");
requireElement(container, '[aria-label="Talk options"]', "Talk options button");
expect(container.querySelector('[aria-label="Voice input"]')).toBeNull();
});
@@ -1495,9 +1571,11 @@ describe("chat session controls", () => {
.querySelector<HTMLButtonElement>('button[data-chat-session-select="true"]')
?.getAttribute("aria-label"),
).toBe(t("chat.selectors.session"));
expect(
[...container.querySelectorAll("select")].map((select) => select.getAttribute("aria-label")),
).toEqual([t("chat.selectors.model"), t("chat.selectors.thinkingLevel")]);
const combinedLabel = container
.querySelector('[data-chat-model-select="true"]')
?.getAttribute("aria-label");
expect(combinedLabel).toContain(t("chat.selectors.model"));
expect(combinedLabel).toContain(t("chat.selectors.thinkingLevel"));
});
it("searches chat sessions inside the picker without replacing recent sessions", async () => {
@@ -2091,7 +2169,7 @@ describe("chat session controls", () => {
]),
);
void switchChatSession(state, "agent:ops:main");
switchChatSession(state, "agent:ops:main");
expect(state.chatSessionPickerResult).toBeNull();
expect(state.chatSessionPickerAppliedQuery).toBe("");
@@ -2380,10 +2458,9 @@ describe("chat session controls", () => {
render(renderChatSessionSelect(state), container);
const modelSelect = getChatModelSelect(container);
expect(modelSelect.value).toBe("");
expect(getChatSelectValue(modelSelect)).toBe("");
modelSelect.value = "openai/gpt-5-mini";
modelSelect.dispatchEvent(new Event("change", { bubbles: true }));
clickChatModelOption(container, "openai/gpt-5-mini");
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
@@ -2407,10 +2484,9 @@ describe("chat session controls", () => {
render(renderChatSessionSelect(state), container);
const modelSelect = getChatModelSelect(container);
expect(modelSelect.value).toBe("openai/gpt-5-mini");
expect(getChatSelectValue(modelSelect)).toBe("openai/gpt-5-mini");
modelSelect.value = "";
modelSelect.dispatchEvent(new Event("change", { bubbles: true }));
clickChatModelOption(container, "");
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
@@ -2421,6 +2497,70 @@ describe("chat session controls", () => {
expect(state.sessionsResult?.sessions[0]?.model).toBeUndefined();
});
it("keeps Default available when an explicit model override matches the default", async () => {
const { state, request } = createChatHeaderState({ model: "gpt-5" });
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
clickChatModelOption(container, "");
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
model: null,
});
});
it("scopes composer speed changes for a selected global-session agent", async () => {
const { state, request } = createChatHeaderState();
state.sessionKey = "global";
state.settings.sessionKey = "global";
state.assistantAgentId = "beta";
state.sessionsResult = createSessionsResultFromRows([
{
key: "global",
kind: "global",
modelProvider: "openai",
model: "gpt-5",
updatedAt: 1,
},
]);
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
clickChatSpeedOption(container, "on");
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "global",
agentId: "beta",
fastMode: true,
});
});
it("shows existing speed overrides for providers outside the fast-mode allowlist", async () => {
const { state, request } = createChatHeaderState();
state.sessionsResult = createSessionsResultFromRows([
{
key: "main",
kind: "direct",
modelProvider: "custom",
model: "local-model",
fastMode: true,
updatedAt: 1,
},
]);
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
expect(container.querySelectorAll("[data-chat-speed-option]").length).toBe(3);
clickChatSpeedOption(container, "");
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
fastMode: null,
});
});
it("disables the chat header model picker while a run is active", () => {
const { state } = createChatHeaderState();
state.chatRunId = "run-123";
@@ -2429,7 +2569,7 @@ describe("chat session controls", () => {
render(renderChatSessionSelect(state), container);
const modelSelect = getChatModelSelect(container);
expect(modelSelect.disabled).toBe(true);
expect(modelSelect.getAttribute("aria-disabled")).toBe("true");
});
it("keeps the selected model visible when the active session is absent from sessions.list", async () => {
@@ -2437,15 +2577,12 @@ describe("chat session controls", () => {
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = getChatModelSelect(container);
modelSelect.value = "openai/gpt-5-mini";
modelSelect.dispatchEvent(new Event("change", { bubbles: true }));
clickChatModelOption(container, "openai/gpt-5-mini");
await flushTasks();
render(renderChatSessionSelect(state), container);
const rerendered = getChatModelSelect(container);
expect(rerendered.value).toBe("openai/gpt-5-mini");
expect(getChatSelectValue(rerendered)).toBe("openai/gpt-5-mini");
});
it("keeps the selected model visible after switching away and back to a session", async () => {
@@ -2505,22 +2642,21 @@ describe("chat session controls", () => {
render(renderChatSessionSelect(state), container);
const modelSelect = getChatModelSelect(container);
expect(modelSelect.value).toBe("");
expect(getChatSelectValue(modelSelect)).toBe("");
modelSelect.value = "bedrock/claude-opus-4.5";
modelSelect.dispatchEvent(new Event("change", { bubbles: true }));
clickChatModelOption(container, "bedrock/claude-opus-4.5");
await flushTasks();
state.sessionKey = sessionB;
state.settings.sessionKey = sessionB;
render(renderChatSessionSelect(state), container);
expect(getChatModelSelect(container).value).toBe("");
expect(getChatSelectValue(getChatModelSelect(container))).toBe("");
state.sessionKey = sessionA;
state.settings.sessionKey = sessionA;
render(renderChatSessionSelect(state), container);
expect(getChatModelSelect(container).value).toBe("bedrock/claude-opus-4.5");
expect(getChatSelectValue(getChatModelSelect(container))).toBe("bedrock/claude-opus-4.5");
});
it("uses default thinking options when the active session is absent", () => {
@@ -2539,20 +2675,22 @@ describe("chat session controls", () => {
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const thinkingSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-thinking-select="true"]',
);
const thinkingOptions = getThinkingOptions(container);
expect([...(thinkingSelect?.options ?? [])].map((option) => option.value)).toEqual([
expect(thinkingOptions.map((option) => option.dataset.chatThinkingOption)).toEqual([
"",
"off",
"adaptive",
"xhigh",
"max",
]);
expect(
[...(thinkingSelect?.options ?? [])].map((option) => option.textContent?.trim()),
).toEqual(["Inherited: Off", "Off", "Adaptive", "Extra high", "Maximum"]);
expect(thinkingOptions.map((option) => option.textContent?.trim())).toEqual([
"Default",
"Off",
"Adaptive",
"Extra high",
"Maximum",
]);
});
it("labels chat thinking default from the active session row", () => {
@@ -2564,13 +2702,12 @@ describe("chat session controls", () => {
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const thinkingSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-thinking-select="true"]',
);
const thinkingSelect = getThinkingSelect(container);
const thinkingOptions = getThinkingOptions(container);
expect(thinkingSelect?.value).toBe("");
expect(thinkingSelect?.options[0]?.textContent?.trim()).toBe("Inherited: Adaptive");
expect(thinkingSelect?.title).toBe("Inherited: Adaptive");
expect(getChatThinkingValue(thinkingSelect)).toBe("");
expect(thinkingOptions[0]?.textContent?.trim()).toBe("Default");
expect(thinkingSelect.title).toContain("Adaptive");
});
it("disables thinking for known non-reasoning models without duplicate off options", () => {
@@ -2604,15 +2741,12 @@ describe("chat session controls", () => {
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const thinkingSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-thinking-select="true"]',
);
const thinkingSelect = getThinkingSelect(container);
const thinkingOptions = getThinkingOptions(container);
expect(thinkingSelect?.disabled).toBe(true);
expect([...(thinkingSelect?.options ?? [])].map((option) => option.value)).toEqual([""]);
expect(
[...(thinkingSelect?.options ?? [])].map((option) => option.textContent?.trim()),
).toEqual(["Inherited: Off"]);
expect(thinkingSelect.dataset.chatThinkingDisabled).toBe("true");
expect(thinkingOptions.map((option) => option.dataset.chatThinkingOption)).toEqual([""]);
expect(thinkingOptions.map((option) => option.textContent?.trim())).toEqual(["Default"]);
});
it("does not label a non-default chat model from global thinking defaults", () => {
@@ -2639,11 +2773,9 @@ describe("chat session controls", () => {
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const thinkingSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-thinking-select="true"]',
);
const thinkingOptions = getThinkingOptions(container);
expect(thinkingSelect?.options[0]?.textContent?.trim()).toBe("Inherited: Low");
expect(thinkingOptions[0]?.textContent?.trim()).toBe("Default");
});
it("always renders full thinking labels", () => {
@@ -2667,15 +2799,14 @@ describe("chat session controls", () => {
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const thinkingSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-thinking-select="true"]',
);
const thinkingSelect = getThinkingSelect(container);
const thinkingOptions = getThinkingOptions(container);
expect(container.querySelector('select[data-chat-thinking-select-compact="true"]')).toBeNull();
expect(thinkingSelect?.value).toBe("");
expect(thinkingSelect?.title).toBe("Inherited: High");
expect([...thinkingSelect!.options].map((option) => option.textContent?.trim())).toEqual([
"Inherited: High",
expect(container.querySelector('[data-chat-thinking-select-compact="true"]')).toBeNull();
expect(getChatThinkingValue(thinkingSelect)).toBe("");
expect(thinkingSelect.title).toContain("High");
expect(thinkingOptions.map((option) => option.textContent?.trim())).toEqual([
"Default",
"Off",
"Low",
"Medium",
@@ -2692,12 +2823,11 @@ describe("chat session controls", () => {
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const thinkingSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-thinking-select="true"]',
);
const thinkingSelect = getThinkingSelect(container);
const thinkingOptions = getThinkingOptions(container);
expect(thinkingSelect?.value).toBe("");
expect(thinkingSelect?.options[0]?.textContent?.trim()).toBe("Inherited: Adaptive");
expect(thinkingSelect?.title).toBe("Inherited: Adaptive");
expect(getChatThinkingValue(thinkingSelect)).toBe("");
expect(thinkingOptions[0]?.textContent?.trim()).toBe("Default");
expect(thinkingSelect.title).toContain("Adaptive");
});
});

View File

@@ -54,7 +54,12 @@ import { icons } from "../icons.ts";
import { formatGoalDetail, formatGoalSummary } from "../session-goal.ts";
import type { SidebarContent } from "../sidebar-content.ts";
import { detectTextDirection } from "../text-direction.ts";
import type { SessionGoal, SessionsListResult } from "../types.ts";
import type {
AgentFileEntry,
AgentsFilesListResult,
SessionGoal,
SessionsListResult,
} from "../types.ts";
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
import { resolveLocalUserName } from "../user-identity.ts";
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
@@ -119,7 +124,6 @@ export type ChatProps = {
disabledReason: string | null;
error: string | null;
sessions: SessionsListResult | null;
focusMode: boolean;
sidebarOpen?: boolean;
sidebarContent?: SidebarContent | null;
sidebarError?: string | null;
@@ -139,7 +143,6 @@ export type ChatProps = {
showNewMessages?: boolean;
onScrollToBottom?: () => void;
onRefresh: () => void;
onToggleFocusMode: () => void;
getDraft?: () => string;
onDraftChange: (next: string) => void;
onRequestUpdate?: () => void;
@@ -174,6 +177,16 @@ export type ChatProps = {
onSplitRatioChange?: (ratio: number) => void;
onChatScroll?: (event: Event) => void;
basePath?: string;
composerControls?: TemplateResult | typeof nothing;
workspaceFiles?: {
agentId: string;
list: AgentsFilesListResult | null;
loading: boolean;
error: string | null;
activeName: string | null;
onRefresh: () => void;
onOpenFile: (name: string) => void;
};
};
const pinnedMessagesMap = new Map<string, PinnedMessages>();
@@ -653,6 +666,92 @@ function renderChatGoal(goal: SessionGoal | undefined): TemplateResult | typeof
`;
}
function formatWorkspaceFileSize(file: AgentFileEntry): string {
const size = file.size;
if (typeof size !== "number" || !Number.isFinite(size) || size < 0) {
return "";
}
if (size >= 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(1).replace(/\.0$/, "")} MB`;
}
if (size >= 1024) {
return `${(size / 1024).toFixed(1).replace(/\.0$/, "")} KB`;
}
return `${size} B`;
}
function renderWorkspaceFileRail(
workspaceFiles: NonNullable<ChatProps["workspaceFiles"]> | undefined,
): TemplateResult | typeof nothing {
if (!workspaceFiles) {
return nothing;
}
const files = workspaceFiles.list?.files ?? [];
return html`
<aside class="chat-workspace-rail" aria-label="Workspace files">
<div class="chat-workspace-rail__header">
<div class="chat-workspace-rail__title">
<span class="chat-workspace-rail__eyebrow">Workspace</span>
<strong>Files</strong>
</div>
<button
class="btn btn--ghost btn--sm chat-workspace-rail__refresh"
type="button"
title="Refresh files"
aria-label="Refresh files"
?disabled=${workspaceFiles.loading}
@click=${workspaceFiles.onRefresh}
>
${icons.refresh}
</button>
</div>
${workspaceFiles.list?.workspace
? html`<div class="chat-workspace-rail__path" title=${workspaceFiles.list.workspace}>
${workspaceFiles.list.workspace}
</div>`
: nothing}
${workspaceFiles.error
? html`<div class="chat-workspace-rail__state chat-workspace-rail__state--error">
${workspaceFiles.error}
</div>`
: workspaceFiles.loading && files.length === 0
? html`<div class="chat-workspace-rail__state">Loading files...</div>`
: files.length === 0
? html`<div class="chat-workspace-rail__state">No workspace files</div>`
: html`
<div class="chat-workspace-rail__list" role="list">
${files.map((file) => {
const size = formatWorkspaceFileSize(file);
const isActive = file.name === workspaceFiles.activeName;
return html`
<button
class="chat-workspace-rail__file ${isActive
? "chat-workspace-rail__file--active"
: ""}"
type="button"
role="listitem"
title=${file.path || file.name}
@click=${() => workspaceFiles.onOpenFile(file.name)}
>
<span class="chat-workspace-rail__file-icon">${icons.fileText}</span>
<span class="chat-workspace-rail__file-main">
<span class="chat-workspace-rail__file-name">${file.name}</span>
${size
? html`<span class="chat-workspace-rail__file-meta">${size}</span>`
: nothing}
</span>
${file.missing
? html`<span class="chat-workspace-rail__file-badge">Missing</span>`
: nothing}
</button>
`;
})}
</div>
`}
</aside>
`;
}
function resetSlashMenuState(): void {
vs.slashMenuMode = "command";
vs.slashMenuCommand = null;
@@ -1267,10 +1366,12 @@ export function renderChat(props: ChatProps) {
showReasoning,
showToolCalls: props.showToolCalls,
autoExpandToolCalls: Boolean(props.autoExpandToolCalls),
isToolMessageExpanded: (messageId: string) =>
expandedToolCards.get(messageId) ?? false,
onToggleToolMessageExpanded: (messageId: string) => {
expandedToolCards.set(messageId, !expandedToolCards.get(messageId));
isToolMessageExpanded: (messageId: string) => expandedToolCards.get(messageId),
onToggleToolMessageExpanded: (messageId: string, expanded?: boolean) => {
expandedToolCards.set(
messageId,
!(expanded ?? expandedToolCards.get(messageId) ?? false),
);
requestUpdate();
},
isToolExpanded: (toolCardId: string) => expandedToolCards.get(toolCardId) ?? false,
@@ -1459,57 +1560,47 @@ export function renderChat(props: ChatProps) {
</div>
`
: nothing}
${props.focusMode
? html`
<button
class="chat-focus-exit"
type="button"
@click=${props.onToggleFocusMode}
aria-label="Exit focus mode"
title="Exit focus mode"
>
${icons.x}
</button>
`
: nothing}
${renderSearchBar(requestUpdate)} ${renderPinnedSection(props, pinned, requestUpdate)}
<div class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}">
<div
class="chat-main"
style="flex: ${sidebarOpen ? `0 0 ${splitRatio * 100}%` : "1 1 100%"}"
>
${thread}
</div>
<div class="chat-workbench">
<div class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}">
<div
class="chat-main"
style="flex: ${sidebarOpen ? `0 0 ${splitRatio * 100}%` : "1 1 100%"}"
>
${thread}
</div>
${sidebarOpen
? html`
<resizable-divider
.splitRatio=${splitRatio}
.label=${t("nav.resize")}
@resize=${(e: CustomEvent) => props.onSplitRatioChange?.(e.detail.splitRatio)}
></resizable-divider>
<div class="chat-sidebar" @click=${handleCodeBlockCopy}>
${renderMarkdownSidebar({
content: props.sidebarContent ?? null,
error: props.sidebarError ?? null,
canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl,
embedSandboxMode: props.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false,
onClose: props.onCloseSidebar!,
onViewRawText: () => {
if (!props.onOpenSidebar) {
return;
}
const rawContent = buildRawSidebarContent(props.sidebarContent);
if (rawContent) {
props.onOpenSidebar(rawContent);
}
},
})}
</div>
`
: nothing}
${sidebarOpen
? html`
<resizable-divider
.splitRatio=${splitRatio}
.label=${t("nav.resize")}
@resize=${(e: CustomEvent) => props.onSplitRatioChange?.(e.detail.splitRatio)}
></resizable-divider>
<div class="chat-sidebar" @click=${handleCodeBlockCopy}>
${renderMarkdownSidebar({
content: props.sidebarContent ?? null,
error: props.sidebarError ?? null,
canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl,
embedSandboxMode: props.embedSandboxMode ?? "scripts",
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false,
onClose: props.onCloseSidebar!,
onViewRawText: () => {
if (!props.onOpenSidebar) {
return;
}
const rawContent = buildRawSidebarContent(props.sidebarContent);
if (rawContent) {
props.onOpenSidebar(rawContent);
}
},
})}
</div>
`
: nothing}
</div>
${renderWorkspaceFileRail(props.workspaceFiles)}
</div>
${renderChatQueue({
@@ -1520,14 +1611,6 @@ export function renderChat(props: ChatProps) {
onQueueRemove: props.onQueueRemove,
})}
${renderSideResult(props.sideResult, props.onDismissSideResult)}
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null, {
compactBusy,
compactDisabled: !props.connected || isBusy || showAbortableUi,
onCompact: props.onCompact,
})}
${renderChatGoal(activeSession?.goal)}
${props.showNewMessages
? html`
<button class="chat-new-messages" type="button" @click=${props.onScrollToBottom}>
@@ -1542,6 +1625,16 @@ export function renderChat(props: ChatProps) {
@click=${(event: MouseEvent) => focusComposerFromChrome(event, props.connected)}
>
${renderSlashMenu(requestUpdate, props)} ${renderAttachmentPreview(props)}
<div class="agent-chat__composer-status-stack">
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null, {
compactBusy,
compactDisabled: !props.connected || isBusy || showAbortableUi,
onCompact: props.onCompact,
})}
${renderChatGoal(activeSession?.goal)}
</div>
<input
type="file"
@@ -1623,26 +1716,35 @@ export function renderChat(props: ChatProps) {
: t("chat.composer.startTalk")}
?disabled=${!props.connected}
>
${props.realtimeTalkActive ? icons.volume2 : icons.radio}
${props.realtimeTalkActive ? icons.volume2 : icons.mic}
<span class="agent-chat__control-label"
>${props.realtimeTalkActive
? t("chat.composer.stopTalk")
: t("chat.composer.startTalk")}</span
>
</button>
`
: nothing}
${props.onToggleRealtimeTalkOptions
? html`
<button
class="agent-chat__input-btn ${props.realtimeTalkOptionsOpen
? "agent-chat__input-btn--active"
? "agent-chat__input-btn--talk"
: ""}"
@click=${props.onToggleRealtimeTalkOptions}
title="Talk options"
aria-label="Talk options"
title="Talk settings"
aria-label="Talk settings"
aria-expanded=${props.realtimeTalkOptionsOpen ? "true" : "false"}
?disabled=${!props.connected || props.realtimeTalkActive}
>
${icons.settings}
<span class="agent-chat__control-label">Talk settings</span>
</button>
`
: nothing}
${props.composerControls
? html`<div class="agent-chat__composer-controls">${props.composerControls}</div>`
: nothing}
${tokens ? html`<span class="agent-chat__token-count">${tokens}</span>` : nothing}
${renderChatRunStatusIndicator(composerRunStatus)}
</div>
@@ -1659,6 +1761,7 @@ export function renderChat(props: ChatProps) {
onNewSession: props.onNewSession,
onSend: props.onSend,
onStoreDraft: () => {},
showSecondary: false,
})}
</div>
</div>

View File

@@ -23,7 +23,6 @@ function createState(overrides: Partial<AppViewState> = {}): AppViewState {
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,

View File

@@ -18,7 +18,6 @@ function createOverviewProps(overrides: Partial<OverviewProps> = {}): OverviewPr
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,