mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat: calm composer controls (#88772)
This commit is contained in:
committed by
GitHub
parent
56b8030cd9
commit
2b30951b80
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/ar.meta.json
generated
6
ui/src/i18n/.i18n/ar.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/de.meta.json
generated
6
ui/src/i18n/.i18n/de.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/es.meta.json
generated
6
ui/src/i18n/.i18n/es.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/fa.meta.json
generated
6
ui/src/i18n/.i18n/fa.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/fr.meta.json
generated
6
ui/src/i18n/.i18n/fr.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/id.meta.json
generated
6
ui/src/i18n/.i18n/id.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/it.meta.json
generated
6
ui/src/i18n/.i18n/it.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/ja-JP.meta.json
generated
6
ui/src/i18n/.i18n/ja-JP.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/ko.meta.json
generated
6
ui/src/i18n/.i18n/ko.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/nl.meta.json
generated
6
ui/src/i18n/.i18n/nl.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/pl.meta.json
generated
6
ui/src/i18n/.i18n/pl.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/pt-BR.meta.json
generated
6
ui/src/i18n/.i18n/pt-BR.meta.json
generated
@@ -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,
|
||||
|
||||
166
ui/src/i18n/.i18n/raw-copy-baseline.json
generated
166
ui/src/i18n/.i18n/raw-copy-baseline.json
generated
@@ -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",
|
||||
|
||||
6
ui/src/i18n/.i18n/th.meta.json
generated
6
ui/src/i18n/.i18n/th.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/tr.meta.json
generated
6
ui/src/i18n/.i18n/tr.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/uk.meta.json
generated
6
ui/src/i18n/.i18n/uk.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/vi.meta.json
generated
6
ui/src/i18n/.i18n/vi.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/zh-CN.meta.json
generated
6
ui/src/i18n/.i18n/zh-CN.meta.json
generated
@@ -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,
|
||||
|
||||
6
ui/src/i18n/.i18n/zh-TW.meta.json
generated
6
ui/src/i18n/.i18n/zh-TW.meta.json
generated
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -139,7 +139,6 @@ function createHost(): TestGatewayHost {
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "system",
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
|
||||
@@ -119,7 +119,6 @@ function createHost() {
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "system",
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
chatShowToolCalls: true,
|
||||
splitRatio: 0.6,
|
||||
|
||||
@@ -60,7 +60,6 @@ function createState(overrides: Partial<AppViewState> = {}): AppViewState {
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
textScale: 100,
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: false,
|
||||
chatShowToolCalls: true,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user