From 2b30951b8090cfea0149c3cd90fe3a43d3044bc1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 23:37:27 +0100 Subject: [PATCH] feat: calm composer controls (#88772) --- scripts/control-ui-mock-dev.ts | 71 ++ ...-app-settings.agents-files-refresh.test.ts | 1 - ui/src/i18n/.i18n/ar.meta.json | 6 +- ui/src/i18n/.i18n/de.meta.json | 6 +- ui/src/i18n/.i18n/es.meta.json | 6 +- ui/src/i18n/.i18n/fa.meta.json | 6 +- ui/src/i18n/.i18n/fr.meta.json | 6 +- ui/src/i18n/.i18n/id.meta.json | 6 +- ui/src/i18n/.i18n/it.meta.json | 6 +- ui/src/i18n/.i18n/ja-JP.meta.json | 6 +- ui/src/i18n/.i18n/ko.meta.json | 6 +- ui/src/i18n/.i18n/nl.meta.json | 6 +- ui/src/i18n/.i18n/pl.meta.json | 6 +- ui/src/i18n/.i18n/pt-BR.meta.json | 6 +- ui/src/i18n/.i18n/raw-copy-baseline.json | 166 +++- ui/src/i18n/.i18n/th.meta.json | 6 +- ui/src/i18n/.i18n/tr.meta.json | 6 +- ui/src/i18n/.i18n/uk.meta.json | 6 +- ui/src/i18n/.i18n/vi.meta.json | 6 +- ui/src/i18n/.i18n/zh-CN.meta.json | 6 +- ui/src/i18n/.i18n/zh-TW.meta.json | 6 +- ui/src/styles/chat/layout.css | 606 +++++++++-- ui/src/styles/chat/sidebar.css | 167 ++++ ui/src/styles/chat/tool-cards.css | 92 ++ ui/src/styles/components.css | 7 - ui/src/styles/layout.css | 191 +--- ui/src/styles/layout.mobile.css | 82 +- ui/src/styles/layout.mobile.test.ts | 104 +- ui/src/test-helpers/control-ui-e2e.ts | 11 + ui/src/ui/app-chat.ts | 5 +- ui/src/ui/app-gateway.node.test.ts | 1 - ui/src/ui/app-gateway.sessions.node.test.ts | 1 - ui/src/ui/app-render.assistant-avatar.test.ts | 1 - ui/src/ui/app-render.helpers.browser.test.ts | 49 +- ui/src/ui/app-render.helpers.node.test.ts | 47 +- ui/src/ui/app-render.helpers.ts | 331 +++--- ui/src/ui/app-render.ts | 938 +++++------------- ui/src/ui/app-scroll.test.ts | 21 +- ui/src/ui/app-scroll.ts | 15 +- ui/src/ui/app-settings.test.ts | 2 - ui/src/ui/app-view-state.ts | 31 +- ui/src/ui/app.ts | 70 +- .../ui/chat/chat-responsive.browser.test.ts | 38 +- ui/src/ui/chat/grouped-render.test.ts | 166 +++- ui/src/ui/chat/grouped-render.ts | 149 ++- ui/src/ui/chat/run-controls.ts | 35 +- ui/src/ui/chat/session-controls.ts | 466 +++++++-- ui/src/ui/chat/slash-command-executor.ts | 12 +- ui/src/ui/chat/slash-commands.node.test.ts | 4 - ui/src/ui/chat/slash-commands.ts | 5 - ui/src/ui/components/tooltip.ts | 223 ----- ui/src/ui/controllers/agent-files.ts | 9 +- ui/src/ui/e2e/chat-flow.e2e.test.ts | 45 +- ui/src/ui/navigation.browser.test.ts | 61 +- ui/src/ui/storage.node.test.ts | 15 - ui/src/ui/storage.ts | 5 - ui/src/ui/views/chat.test.ts | 276 ++++-- ui/src/ui/views/chat.ts | 237 +++-- ui/src/ui/views/login-gate.test.ts | 1 - ui/src/ui/views/overview.render.test.ts | 1 - 60 files changed, 2681 insertions(+), 2185 deletions(-) delete mode 100644 ui/src/ui/components/tooltip.ts diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index ec2afaa3ed55..439abc0d24b9 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -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, diff --git a/src/ui-app-settings.agents-files-refresh.test.ts b/src/ui-app-settings.agents-files-refresh.test.ts index 029787ea97a2..2c186de61849 100644 --- a/src/ui-app-settings.agents-files-refresh.test.ts +++ b/src/ui-app-settings.agents-files-refresh.test.ts @@ -67,7 +67,6 @@ function createHost(agentsPanel: AgentsPanel): Parameters .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; } diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index b701fd7d1627..6e0668d057c6 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -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 { diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index 3abca5e32126..ec71c54bf914 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -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%, diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 0d7ae0529d15..fdc67b9de793 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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; } diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index ccbf6104cf55..ac4961520184 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -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; diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 9c8cb6839fdc..cbd15e33da7e 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -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; diff --git a/ui/src/styles/layout.mobile.test.ts b/ui/src/styles/layout.mobile.test.ts index 8af62be96ca6..3352676f5368 100644 --- a/ui/src/styles/layout.mobile.test.ts +++ b/ui/src/styles/layout.mobile.test.ts @@ -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);"), diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 2084e09dbeeb..0f59986e8f7e 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -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, diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index d6e54070a3de..37ed8b8248dc 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -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; diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 88125bfd8e3d..616be91c38dd 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -139,7 +139,6 @@ function createHost(): TestGatewayHost { sessionKey: "main", lastActiveSessionKey: "main", theme: "system", - chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, diff --git a/ui/src/ui/app-gateway.sessions.node.test.ts b/ui/src/ui/app-gateway.sessions.node.test.ts index 5960d9582caf..2ffc0959125a 100644 --- a/ui/src/ui/app-gateway.sessions.node.test.ts +++ b/ui/src/ui/app-gateway.sessions.node.test.ts @@ -119,7 +119,6 @@ function createHost() { lastActiveSessionKey: "main", theme: "claw", themeMode: "system", - chatFocusMode: false, chatShowThinking: true, chatShowToolCalls: true, splitRatio: 0.6, diff --git a/ui/src/ui/app-render.assistant-avatar.test.ts b/ui/src/ui/app-render.assistant-avatar.test.ts index b62268d252e3..bfcda92d9984 100644 --- a/ui/src/ui/app-render.assistant-avatar.test.ts +++ b/ui/src/ui/app-render.assistant-avatar.test.ts @@ -60,7 +60,6 @@ function createState(overrides: Partial = {}): AppViewState { navGroupsCollapsed: {}, borderRadius: 50, textScale: 100, - chatFocusMode: false, chatShowThinking: false, chatShowToolCalls: true, }, diff --git a/ui/src/ui/app-render.helpers.browser.test.ts b/ui/src/ui/app-render.helpers.browser.test.ts index bd3549ae3fe1..652b14a18e0d 100644 --- a/ui/src/ui/app-render.helpers.browser.test.ts +++ b/ui/src/ui/app-render.helpers.browser.test.ts @@ -48,7 +48,6 @@ function createState(overrides: Partial = {}) { navCollapsed: false, navGroupsCollapsed: {}, borderRadius: 50, - chatFocusMode: false, chatShowThinking: false, chatShowToolCalls: true, chatAutoScroll: "near-bottom", @@ -72,20 +71,6 @@ function createState(overrides: Partial = {}) { } as unknown as AppViewState; } -function renderRefreshButton(overrides: Partial = {}) { - const container = document.createElement("div"); - render(renderChatControls(createState(overrides)), container); - - const button = container.querySelector( - `.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(".chat-controls .btn--icon[data-tooltip]"), + container.querySelectorAll( + ".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(".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('[data-chat-model-select="true"]')?.tagName).toBe( + "SUMMARY", + ); + expect( + container.querySelector('[data-chat-thinking-select="true"]')?.tagName, + ).toBe("SUMMARY"); const autoScrollToggle = requireButton( container.querySelector('[data-chat-auto-scroll-toggle="true"]'), "auto-scroll toggle", diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index d02706f67e8b..06bc6908e742 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -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((resolve) => { - resolveHistory = resolve; - }); - const subscriptionSynced = new Promise((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( diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 2e6fbad7345d..228fb6068e5a 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -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` { 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` `; } @@ -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` `; - const refreshIcon = html` - - - - - `; - const focusIcon = html` - - - - - - - - `; + const settingsOpen = state.chatMobileControlsOpen; + const settingsLabel = t("chat.settings"); + const settingsTitle = t("chat.settings"); + return html` -
+
{ + if (state.chatMobileControlsOpen) { + state.setChatMobileControlsOpen(false); + } + }} + > + ${renderChatModelSelect(state)} +
+
- | - ${renderChatAutoScrollToggle(state)} - - - - +
+ ${settingsLabel} +
+ + ${renderChatAutoScrollToggle(state, { labelled: true })} + + + +
+
+
`; } @@ -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) { > `; - const focusIcon = html` - - - - - - - - `; return html`
@@ -592,13 +567,7 @@ export function renderChatMobileToggle(state: AppViewState) { }} >
- ${renderChatSessionSelectBase( - state, - (targetState, nextSessionKey) => { - void switchChatSession(targetState, nextSessionKey); - }, - { surface: "mobile" }, - )} + ${renderChatSessionSelectBase(state, switchChatSession, { surface: "mobile" })}
${renderChatAutoScrollToggle(state)} - + ${collapsed || recent.length === 0 ? nothing : html` @@ -409,6 +404,7 @@ function renderSidebarRecentSession(state: AppViewState, row: GatewaySessionRow) { if ( @@ -423,7 +419,7 @@ function renderSidebarRecentSession(state: AppViewState, row: GatewaySessionRow) } event.preventDefault(); if (row.key !== state.sessionKey) { - void switchChatSession(state, row.key); + switchChatSession(state, row.key); } state.setTab("chat" as import("./navigation.ts").Tab); }} @@ -455,12 +451,40 @@ const lazyLogs = createLazyView(() => import("./views/logs.ts"), notifyLazyViewC const lazyNodes = createLazyView(() => import("./views/nodes.ts"), notifyLazyViewChanged); const lazySessions = createLazyView(() => import("./views/sessions.ts"), notifyLazyViewChanged); const lazySkills = createLazyView(() => import("./views/skills.ts"), notifyLazyViewChanged); -const lazySkillWorkshop = createLazyView( - () => import("./views/skill-workshop.ts"), - notifyLazyViewChanged, -); const lazyWorkboard = createLazyView(() => import("./views/workboard.ts"), notifyLazyViewChanged); +type ChatWorkspaceFilesState = { + activeName: string | null; + agentId: string; + error: string | null; + list: AgentsFilesListResult | null; + loading: boolean; + requestId: number; +}; + +const chatWorkspaceFilesStates = new WeakMap(); +const chatWorkspaceFileOpenRequests = new WeakMap< + AppViewState, + { agentId: string; id: number; name: string; sessionKey: string } +>(); + +function getChatWorkspaceFilesState(state: AppViewState, agentId: string): ChatWorkspaceFilesState { + const current = chatWorkspaceFilesStates.get(state); + if (current?.agentId === agentId) { + return current; + } + const next = { + activeName: null, + agentId, + error: null, + list: null, + loading: false, + requestId: 0, + }; + chatWorkspaceFilesStates.set(state, next); + return next; +} + export function formatDreamNextCycle(nextRunAtMs: number | undefined): string | null { return ( formatTimeMs( @@ -495,20 +519,6 @@ function resolveDreamingNextCycle( let clawhubSearchTimer: ReturnType | null = null; const UPDATE_BANNER_DISMISS_KEY = "openclaw:control-ui:update-banner-dismissed:v1"; -const SKILL_WORKSHOP_REVIEWED_KEY = "openclaw:control-ui:skill-workshop-reviewed:v1"; -const SKILL_WORKSHOP_QUEUE_WIDTH_KEY = "openclaw:control-ui:skill-workshop-queue-width:v1"; -const SKILL_WORKSHOP_MODE_KEY = "openclaw:control-ui:skill-workshop-mode:v1"; -const SKILL_WORKSHOP_CURRENT_CHAT_REVISIONS_KEY = - "openclaw:control-ui:skill-workshop-current-chat-revisions:v1"; -const SKILL_WORKSHOP_REVISION_SESSIONS_KEY = - "openclaw:control-ui:skill-workshop-revision-sessions:v1"; -const SKILL_WORKSHOP_CHAT_HANDOFF_MS = 900; -const SKILL_WORKSHOP_REVISION_PREPARE_MIN_MS = 700; -const MAX_SKILL_WORKSHOP_REVIEWED_KEYS = 500; -const MAX_SKILL_WORKSHOP_REVISION_SESSIONS = 200; -const DEFAULT_SKILL_WORKSHOP_QUEUE_WIDTH = 360; -const MIN_SKILL_WORKSHOP_QUEUE_WIDTH = 280; -const MAX_SKILL_WORKSHOP_QUEUE_WIDTH = 560; const CRON_THINKING_SUGGESTIONS = ["off", "minimal", "low", "medium", "high"]; const CRON_TIMEZONE_SUGGESTIONS = [ "UTC", @@ -553,489 +563,6 @@ type DismissedUpdateBanner = { dismissedAtMs: number; }; -type SkillWorkshopReviewableProposal = { - key: string; - slug?: string; - status: string; - origin?: { - agentId?: string; - sessionKey?: string; - }; - version: number; - createdAt: number; - updatedAt?: number; - isNew: boolean; -}; - -type SkillWorkshopRevisionSessionEntry = { - sessionKey: string; - updatedAt: number; -}; - -export function loadSkillWorkshopUseCurrentChatForRevisions(): boolean { - return getSafeLocalStorage()?.getItem(SKILL_WORKSHOP_CURRENT_CHAT_REVISIONS_KEY) === "true"; -} - -function setSkillWorkshopUseCurrentChatForRevisions(state: AppViewState, enabled: boolean): void { - state.skillWorkshopUseCurrentChatForRevisions = enabled; - try { - getSafeLocalStorage()?.setItem(SKILL_WORKSHOP_CURRENT_CHAT_REVISIONS_KEY, String(enabled)); - } catch { - // Storage is only for the UI preference; the current toggle state still applies. - } -} - -export function loadSkillWorkshopRevisionSessions(): Record< - string, - SkillWorkshopRevisionSessionEntry -> { - const raw = getSafeLocalStorage()?.getItem(SKILL_WORKSHOP_REVISION_SESSIONS_KEY); - if (!raw) { - return {}; - } - try { - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return {}; - } - const entries = Object.entries(parsed) - .flatMap(([proposalId, value]) => { - if (!value || typeof value !== "object") { - return []; - } - const record = value as { sessionKey?: unknown; updatedAt?: unknown }; - const sessionKey = normalizeOptionalString(record.sessionKey); - const updatedAt = - typeof record.updatedAt === "number" && Number.isFinite(record.updatedAt) - ? record.updatedAt - : 0; - return proposalId && sessionKey ? [[proposalId, { sessionKey, updatedAt }] as const] : []; - }) - .toSorted((a, b) => b[1].updatedAt - a[1].updatedAt) - .slice(0, MAX_SKILL_WORKSHOP_REVISION_SESSIONS); - return Object.fromEntries(entries); - } catch { - return {}; - } -} - -function saveSkillWorkshopRevisionSessions( - sessions: Record, -): void { - try { - const entries = Object.entries(sessions) - .toSorted((a, b) => b[1].updatedAt - a[1].updatedAt) - .slice(0, MAX_SKILL_WORKSHOP_REVISION_SESSIONS); - getSafeLocalStorage()?.setItem( - SKILL_WORKSHOP_REVISION_SESSIONS_KEY, - JSON.stringify(Object.fromEntries(entries)), - ); - } catch { - // Revision session persistence is a convenience; created sessions remain usable normally. - } -} - -export function loadSkillWorkshopReviewedKeys(): string[] { - const raw = getSafeLocalStorage()?.getItem(SKILL_WORKSHOP_REVIEWED_KEY); - if (!raw) { - return []; - } - try { - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return []; - } - return parsed - .filter((value): value is string => typeof value === "string") - .slice(-MAX_SKILL_WORKSHOP_REVIEWED_KEYS); - } catch { - return []; - } -} - -function saveSkillWorkshopReviewedKeys(keys: string[]): void { - try { - getSafeLocalStorage()?.setItem( - SKILL_WORKSHOP_REVIEWED_KEY, - JSON.stringify(keys.slice(-MAX_SKILL_WORKSHOP_REVIEWED_KEYS)), - ); - } catch { - // Ignore browser storage failures; the dots still work for the current render state. - } -} - -export function loadSkillWorkshopQueueWidth(): number { - const raw = getSafeLocalStorage()?.getItem(SKILL_WORKSHOP_QUEUE_WIDTH_KEY); - if (!raw) { - return DEFAULT_SKILL_WORKSHOP_QUEUE_WIDTH; - } - return clampSkillWorkshopQueueWidth(Number(raw)); -} - -function clampSkillWorkshopQueueWidth(width: number): number { - if (!Number.isFinite(width)) { - return DEFAULT_SKILL_WORKSHOP_QUEUE_WIDTH; - } - return Math.min( - MAX_SKILL_WORKSHOP_QUEUE_WIDTH, - Math.max(MIN_SKILL_WORKSHOP_QUEUE_WIDTH, Math.round(width)), - ); -} - -function setSkillWorkshopQueueWidth( - state: AppViewState, - width: number, - options?: { persist?: boolean }, -): void { - const next = clampSkillWorkshopQueueWidth(width); - state.skillWorkshopQueueWidth = next; - if (options?.persist) { - try { - getSafeLocalStorage()?.setItem(SKILL_WORKSHOP_QUEUE_WIDTH_KEY, String(next)); - } catch { - // Width persistence is a convenience; the current drag state still applies. - } - } -} - -export function loadSkillWorkshopMode(): "board" | "today" { - const raw = getSafeLocalStorage()?.getItem(SKILL_WORKSHOP_MODE_KEY); - return raw === "today" ? "today" : "board"; -} - -function setSkillWorkshopMode(state: AppViewState, mode: "board" | "today"): void { - if (state.skillWorkshopMode === mode) { - return; - } - state.skillWorkshopMode = mode; - try { - getSafeLocalStorage()?.setItem(SKILL_WORKSHOP_MODE_KEY, mode); - } catch { - // Mode persistence is a convenience; the in-memory toggle still works. - } -} - -function renderSkillWorkshopHeaderControls(state: AppViewState) { - return html` -
- - - -
- - - -
-
- `; -} - -function skillWorkshopReviewKey(proposal: SkillWorkshopReviewableProposal): string { - return `${proposal.key}:${proposal.version}:${proposal.updatedAt ?? proposal.createdAt}`; -} - -function applySkillWorkshopReviewState( - proposals: T[], - reviewedKeys: string[], -): T[] { - const reviewed = new Set(reviewedKeys); - return proposals.map((proposal) => ({ - ...proposal, - isNew: proposal.status === "pending" && !reviewed.has(skillWorkshopReviewKey(proposal)), - })); -} - -function rememberSkillWorkshopProposalReviewed( - reviewedKeys: string[], - proposal: SkillWorkshopReviewableProposal, -): string[] { - const key = skillWorkshopReviewKey(proposal); - if (reviewedKeys.includes(key)) { - return reviewedKeys; - } - const next = [...reviewedKeys, key].slice(-MAX_SKILL_WORKSHOP_REVIEWED_KEYS); - saveSkillWorkshopReviewedKeys(next); - return next; -} - -function findSkillWorkshopRevisionSessionRow( - state: AppViewState, - sessionKey: string | undefined, -): GatewaySessionRow | null { - const key = normalizeOptionalString(sessionKey); - if (!key) { - return null; - } - const current = state.sessionsResult?.sessions.find((row) => row.key === key); - if (current) { - return current; - } - for (const rows of Object.values(state.chatAgentSessionRowsByAgent ?? {})) { - const cached = rows.find((row) => row.key === key); - if (cached) { - return cached; - } - } - return null; -} - -function isUsableSkillWorkshopRevisionSession( - row: GatewaySessionRow | null, -): row is GatewaySessionRow { - return Boolean(row && !row.archived && !row.hasActiveRun); -} - -function rememberSkillWorkshopRevisionSession( - state: AppViewState, - proposalId: string, - sessionKey: string, -): void { - const key = normalizeOptionalString(proposalId); - const value = normalizeOptionalString(sessionKey); - if (!key || !value) { - return; - } - const next = { - ...state.skillWorkshopRevisionSessions, - [key]: { sessionKey: value, updatedAt: Date.now() }, - }; - state.skillWorkshopRevisionSessions = next; - saveSkillWorkshopRevisionSessions(next); -} - -async function ensureSkillWorkshopRevisionSessionsLoaded( - state: AppViewState, - agentId: string, -): Promise { - const resultAgentId = normalizeOptionalString(state.sessionsResultAgentId); - if (resultAgentId === agentId && state.sessionsResult?.sessions.length) { - return; - } - await loadSessions(state, { - ...createChatSessionsLoadOverrides(state), - agentId, - }); -} - -async function resolveSkillWorkshopRevisionSessionKey( - state: AppViewState, - proposal: SkillWorkshopReviewableProposal, -): Promise { - if (state.skillWorkshopUseCurrentChatForRevisions) { - return normalizeOptionalString(state.sessionKey) ?? null; - } - - const agentId = normalizeAgentId( - proposal.origin?.agentId ?? resolveSidebarSelectedAgentId(state), - ); - await ensureSkillWorkshopRevisionSessionsLoaded(state, agentId); - - const originRow = findSkillWorkshopRevisionSessionRow(state, proposal.origin?.sessionKey); - if (isUsableSkillWorkshopRevisionSession(originRow)) { - return originRow.key; - } - - const mappedSessionKey = state.skillWorkshopRevisionSessions[proposal.key]?.sessionKey; - const mappedRow = findSkillWorkshopRevisionSessionRow(state, mappedSessionKey); - if (isUsableSkillWorkshopRevisionSession(mappedRow)) { - return mappedRow.key; - } - - const labelTarget = normalizeOptionalString(proposal.slug) ?? proposal.key; - const created = await createSessionAndRefresh( - state as unknown as Parameters[0], - { - agentId, - label: `Skill Workshop: ${labelTarget}`.slice(0, 80), - }, - { - ...createChatSessionsLoadOverrides(state), - agentId, - }, - ); - if (created) { - rememberSkillWorkshopRevisionSession(state, proposal.key, created); - } - return created; -} - -async function sendSkillWorkshopRevisionRequest( - state: AppViewState, - message: string, - proposal: SkillWorkshopReviewableProposal, -): Promise { - if (!state.client || !state.connected) { - throw new Error("Gateway is not connected."); - } - const startedAt = Date.now(); - const sessionKey = await resolveSkillWorkshopRevisionSessionKey(state, proposal); - if (!sessionKey) { - throw new Error(state.sessionsError ?? "Could not prepare a Skill Workshop session."); - } - await waitForSkillWorkshopRevisionPrepare(startedAt); - startSkillWorkshopChatHandoff(state); - if (state.tab !== "chat") { - state.setTab("chat" as Tab); - } - if (state.sessionKey === sessionKey) { - await loadChatHistory(state); - } else { - await switchChatSession(state, sessionKey, { awaitInitialLoad: true }); - } - await state.handleSendChat(message); -} - -function waitForSkillWorkshopRevisionPrepare(startedAt: number): Promise { - const remainingMs = SKILL_WORKSHOP_REVISION_PREPARE_MIN_MS - (Date.now() - startedAt); - return remainingMs > 0 - ? new Promise((resolve) => { - globalThis.setTimeout(resolve, remainingMs); - }) - : Promise.resolve(); -} - -function startSkillWorkshopChatHandoff(state: AppViewState): void { - if (state.skillWorkshopChatHandoffTimer) { - globalThis.clearTimeout(state.skillWorkshopChatHandoffTimer); - } - state.skillWorkshopChatHandoffActive = true; - state.skillWorkshopChatHandoffTimer = globalThis.setTimeout(() => { - state.skillWorkshopChatHandoffActive = false; - state.skillWorkshopChatHandoffTimer = null; - }, SKILL_WORKSHOP_CHAT_HANDOFF_MS); -} - -const SKILL_WORKSHOP_HANDOFF_DISMISS_MS = 720; -const SKILL_WORKSHOP_HANDOFF_ERROR_DISMISS_MS = 620; - -function startSkillWorkshopHandoffOverlay( - state: AppViewState, - proposal: { key: string; slug: string }, -): void { - clearSkillWorkshopHandoffOverlay(state, { immediate: true }); - state.skillWorkshopHandoff = { - key: proposal.key, - slug: proposal.slug, - phase: "prepare", - }; -} - -function advanceSkillWorkshopHandoffPhase( - state: AppViewState, - phase: "prepare" | "landing" | "error", -): void { - if (!state.skillWorkshopHandoff) { - return; - } - state.skillWorkshopHandoff = { ...state.skillWorkshopHandoff, phase }; -} - -function finishSkillWorkshopHandoffOverlay(state: AppViewState): void { - if (!state.skillWorkshopHandoff) { - return; - } - advanceSkillWorkshopHandoffPhase(state, "landing"); - if (state.skillWorkshopHandoffDismissTimer) { - globalThis.clearTimeout(state.skillWorkshopHandoffDismissTimer); - } - state.skillWorkshopHandoffDismissTimer = globalThis.setTimeout(() => { - state.skillWorkshopHandoff = null; - state.skillWorkshopHandoffDismissTimer = null; - }, SKILL_WORKSHOP_HANDOFF_DISMISS_MS); -} - -function failSkillWorkshopHandoffOverlay(state: AppViewState): void { - if (!state.skillWorkshopHandoff) { - return; - } - advanceSkillWorkshopHandoffPhase(state, "error"); - if (state.skillWorkshopHandoffDismissTimer) { - globalThis.clearTimeout(state.skillWorkshopHandoffDismissTimer); - } - state.skillWorkshopHandoffDismissTimer = globalThis.setTimeout(() => { - state.skillWorkshopHandoff = null; - state.skillWorkshopHandoffDismissTimer = null; - }, SKILL_WORKSHOP_HANDOFF_ERROR_DISMISS_MS); -} - -function clearSkillWorkshopHandoffOverlay( - state: AppViewState, - options?: { immediate?: boolean }, -): void { - if (state.skillWorkshopHandoffDismissTimer) { - globalThis.clearTimeout(state.skillWorkshopHandoffDismissTimer); - state.skillWorkshopHandoffDismissTimer = null; - } - if (options?.immediate) { - state.skillWorkshopHandoff = null; - } -} - -function renderSkillWorkshopHandoffOverlay(state: AppViewState) { - const handoff = state.skillWorkshopHandoff; - if (!handoff) { - return nothing; - } - return html` - -
- -
- `; -} - function loadDismissedUpdateBanner(): DismissedUpdateBanner | null { try { const raw = getSafeLocalStorage()?.getItem(UPDATE_BANNER_DISMISS_KEY); @@ -1486,6 +1013,14 @@ function renderCronQuickCreateForTab( }); } +function buildWorkspaceFileSidebarContent(name: string, content: string): string { + if (/\.(?:md|markdown|mdx)$/i.test(name)) { + return content; + } + const language = name.match(/\.([a-z0-9_-]+)$/i)?.[1]?.toLowerCase() ?? ""; + return `# ${name}\n\n\`\`\`${language}\n${content}\n\`\`\``; +} + export function renderApp(state: AppViewState) { const updatableState = state as AppViewState & { requestUpdate?: () => void }; const requestHostUpdate = @@ -1507,9 +1042,8 @@ export function renderApp(state: AppViewState) { const isChat = state.tab === "chat"; const headerError = !isChat && state.lastError !== state.chatError ? state.lastError : null; const chatViewError = state.lastError; - const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding); - const chatHeaderHidden = isChat && (chatFocus || state.chatHeaderControlsHidden); - const navDrawerOpen = state.navDrawerOpen && !chatFocus && !state.onboarding; + const chatHeaderHidden = isChat && (state.onboarding || state.chatHeaderControlsHidden); + const navDrawerOpen = state.navDrawerOpen && !state.onboarding; const navCollapsed = state.settings.navCollapsed && !navDrawerOpen; const dashboardHeaderContext = resolveDashboardHeaderContext(state); const showThinking = state.onboarding ? false : state.settings.chatShowThinking; @@ -1671,10 +1205,35 @@ export function renderApp(state: AppViewState) { state.agentsList?.agents?.[0]?.id ?? null; const resolvedAgentId = resolveSelectedAgentId(); - const activeSessionAgentId = resolveAgentIdFromSessionKey(state.sessionKey); - const toolsPanelUsesActiveSession = Boolean( - resolvedAgentId && activeSessionAgentId && resolvedAgentId === activeSessionAgentId, + const normalizedChatSessionKey = normalizeOptionalString(state.sessionKey)?.toLowerCase(); + const activeSessionAgentId = + normalizedChatSessionKey === "global" ? null : resolveAgentIdFromSessionKey(state.sessionKey); + const scopedChatAgentId = scopedAgentParamsForSession(state, state.sessionKey).agentId; + const chatFallbackAgentId = normalizeAgentId( + state.assistantAgentId ?? + state.agentsList?.defaultId ?? + state.agentsList?.agents?.[0]?.id ?? + "main", ); + const resolveChatWorkspaceAgentId = () => { + const normalizedKey = normalizeOptionalString(state.sessionKey)?.toLowerCase(); + const activeAgentId = + normalizedKey === "global" ? null : resolveAgentIdFromSessionKey(state.sessionKey); + const scopedAgentId = scopedAgentParamsForSession(state, state.sessionKey).agentId; + return normalizedKey === "global" + ? (scopedAgentId ?? chatFallbackAgentId) + : (activeAgentId ?? scopedAgentId ?? chatFallbackAgentId); + }; + const chatAgentId = + normalizedChatSessionKey === "global" + ? (scopedChatAgentId ?? chatFallbackAgentId) + : (activeSessionAgentId ?? scopedChatAgentId ?? chatFallbackAgentId); + const toolsPanelUsesActiveSession = Boolean(resolvedAgentId && resolvedAgentId === chatAgentId); + const chatWorkspaceFiles = getChatWorkspaceFilesState(state, chatAgentId); + const currentChatWorkspaceFilesState = () => + resolveChatWorkspaceAgentId() === chatAgentId + ? getChatWorkspaceFilesState(state, chatAgentId) + : null; const getCurrentConfigValue = () => state.configForm ?? (state.configSnapshot?.config as Record | null); const findAgentIndex = (agentId: string) => @@ -2273,6 +1832,114 @@ export function renderApp(state: AppViewState) { state.toolsCatalogLoading = false; resetToolsEffectiveState(state); }; + if ( + isChat && + state.connected && + state.agentsList && + !chatWorkspaceFiles.loading && + !chatWorkspaceFiles.error && + chatWorkspaceFiles.list?.agentId !== chatAgentId + ) { + loadChatWorkspaceFiles(); + } + const refreshChatWorkspaceFiles = () => { + loadChatWorkspaceFiles({ force: true }); + }; + function loadChatWorkspaceFiles(opts?: { force?: boolean }) { + if (!state.client || !state.connected || chatWorkspaceFiles.loading) { + return; + } + const requestId = chatWorkspaceFiles.requestId + 1; + chatWorkspaceFiles.requestId = requestId; + chatWorkspaceFiles.loading = true; + chatWorkspaceFiles.error = null; + if (opts?.force) { + chatWorkspaceFiles.list = null; + } + const requestState = chatWorkspaceFiles; + void (async () => { + try { + const res = await state.client?.request("agents.files.list", { + agentId: chatAgentId, + }); + const current = currentChatWorkspaceFilesState(); + if (current !== requestState || current.requestId !== requestId) { + return; + } + current.list = res ?? null; + if (current.activeName && !res?.files.some((file) => file.name === current.activeName)) { + current.activeName = null; + } + } catch (err) { + const current = currentChatWorkspaceFilesState(); + if (current === requestState && current.requestId === requestId) { + current.error = String(err); + } + } finally { + const current = currentChatWorkspaceFilesState(); + if (current === requestState && current.requestId === requestId) { + current.loading = false; + } + requestHostUpdate?.(); + } + })(); + } + const openChatWorkspaceFile = (name: string) => { + chatWorkspaceFiles.activeName = name; + const previousRequest = chatWorkspaceFileOpenRequests.get(state); + const openRequest = { + agentId: chatAgentId, + id: (previousRequest?.id ?? 0) + 1, + name, + sessionKey: state.sessionKey, + }; + chatWorkspaceFileOpenRequests.set(state, openRequest); + const isCurrentOpenRequest = () => { + const currentRequest = chatWorkspaceFileOpenRequests.get(state); + const currentFiles = currentChatWorkspaceFilesState(); + return ( + currentRequest?.id === openRequest.id && + currentRequest.agentId === resolveChatWorkspaceAgentId() && + currentRequest.name === name && + currentRequest.sessionKey === state.sessionKey && + currentFiles?.activeName === name + ); + }; + void (async () => { + if (!state.client || !state.connected) { + return; + } + chatWorkspaceFiles.error = null; + try { + const res = await state.client.request("agents.files.get", { + agentId: chatAgentId, + name, + }); + const content = res?.file?.content; + if (typeof content !== "string") { + if (isCurrentOpenRequest()) { + chatWorkspaceFiles.error = `Failed to load ${name}`; + requestHostUpdate?.(); + } + return; + } + if (!isCurrentOpenRequest()) { + return; + } + state.handleOpenSidebar({ + kind: "markdown", + content: buildWorkspaceFileSidebarContent(name, content), + rawText: content, + }); + } catch (err) { + if (isCurrentOpenRequest()) { + chatWorkspaceFiles.error = String(err); + } + } finally { + requestHostUpdate?.(); + } + })(); + }; return html` ${renderCommandPalette({ @@ -2297,11 +1964,11 @@ export function renderApp(state: AppViewState) { }, })}
-
+
-
- ${isChat ? renderChatMobileToggle(state) : nothing} - ${renderTopbarThemeModeToggle(state)} -
+
${renderTopbarThemeModeToggle(state)}
@@ -2423,19 +2091,9 @@ export function renderApp(state: AppViewState) { ` : nothing} `; @@ -2484,12 +2142,7 @@ export function renderApp(state: AppViewState) {
${state.updateStatusBanner ? html`` : nothing} - ${state.tab === "config" + ${state.tab === "config" || isChat ? nothing : html`
- ${isChat - ? renderChatSessionSelect(state) - : html`
${titleForTab(state.tab)}
`} - ${isChat ? nothing : html`
${subtitleForTab(state.tab)}
`} +
${titleForTab(state.tab)}
+
${subtitleForTab(state.tab)}
- ${state.tab === "skillWorkshop" - ? renderSkillWorkshopHeaderControls(state) - : nothing} ${state.tab === "dreams" ? html`
@@ -2570,7 +2218,6 @@ export function renderApp(state: AppViewState) { ` : nothing} ${headerError ? html`
${headerError}
` : nothing} - ${isChat ? renderChatControls(state) : nothing}
`} ${state.tab === "overview" @@ -2601,7 +2248,7 @@ export function renderApp(state: AppViewState) { onSettingsChange: (next) => state.applySettings(next), onPasswordChange: (next) => (state.password = next), onSessionKeyChange: (next) => { - void switchChatSession(state, next); + switchChatSession(state, next); }, onToggleGatewayTokenVisibility: () => { state.overviewShowGatewayToken = !state.overviewShowGatewayToken; @@ -2810,7 +2457,7 @@ export function renderApp(state: AppViewState) { } }), onNavigateToChat: (sessionKey) => { - void switchChatSession(state, sessionKey); + switchChatSession(state, sessionKey); state.setTab("chat" as import("./navigation.ts").Tab); }, onAddToWorkboard: @@ -2834,7 +2481,7 @@ export function renderApp(state: AppViewState) { checkpointId, ); if (nextKey) { - void switchChatSession(state, nextKey); + switchChatSession(state, nextKey); state.setTab("chat" as import("./navigation.ts").Tab); } }), @@ -2859,7 +2506,7 @@ export function renderApp(state: AppViewState) { agentsList: state.agentsList, sessions: state.sessionsResult?.sessions ?? [], onOpenSession: (sessionKey) => { - void switchChatSession(state, sessionKey); + switchChatSession(state, sessionKey); state.setTab("chat" as import("./navigation.ts").Tab); }, onRequestUpdate: requestHostUpdate, @@ -2993,7 +2640,7 @@ export function renderApp(state: AppViewState) { await loadCronRuns(state, state.cronRunsJobId); }), onNavigateToChat: (sessionKey) => { - void switchChatSession(state, sessionKey); + switchChatSession(state, sessionKey); state.setTab("chat" as import("./navigation.ts").Tab); }, }), @@ -3096,7 +2743,7 @@ export function renderApp(state: AppViewState) { ) { void loadToolsCatalog(state, resolvedAgentId); } - if (resolvedAgentId === resolveAgentIdFromSessionKey(state.sessionKey)) { + if (resolvedAgentId === chatAgentId) { const toolsRequestKey = buildToolsEffectiveRequestKey(state, { agentId: resolvedAgentId, sessionKey: state.sessionKey, @@ -3376,129 +3023,6 @@ export function renderApp(state: AppViewState) { }), ) : nothing} - ${state.tab === "skillWorkshop" - ? renderLazyView(lazySkillWorkshop, (m) => { - const proposals = applySkillWorkshopReviewState( - state.skillWorkshopProposals, - state.skillWorkshopReviewedKeys, - ); - const counts = countSkillWorkshopProposals(proposals); - const selectedKey = state.skillWorkshopSelectedKey ?? proposals[0]?.key ?? null; - const visibleProposals = m.filterSkillWorkshopProposals( - proposals, - state.skillWorkshopStatusFilter, - state.skillWorkshopQuery, - ); - const currentIndex = visibleProposals.findIndex((p) => p.key === selectedKey); - const goto = (offset: number) => { - if (visibleProposals.length === 0) { - return; - } - const idx = Math.max(0, currentIndex); - const next = (idx + offset + visibleProposals.length) % visibleProposals.length; - const proposal = visibleProposals[next]; - selectSkillWorkshopProposal(state, proposal.key); - state.skillWorkshopReviewedKeys = rememberSkillWorkshopProposalReviewed( - state.skillWorkshopReviewedKeys, - proposal, - ); - state.skillWorkshopFilePreviewKey = null; - state.skillWorkshopFilePreviewQuery = ""; - }; - return m.renderSkillWorkshop({ - loading: state.skillWorkshopLoading, - error: state.skillWorkshopError, - inspectingKey: state.skillWorkshopInspectingKey, - proposals, - selectedKey, - statusFilter: state.skillWorkshopStatusFilter, - query: state.skillWorkshopQuery, - filePreviewKey: state.skillWorkshopFilePreviewKey, - filePreviewQuery: state.skillWorkshopFilePreviewQuery, - queueWidth: state.skillWorkshopQueueWidth, - mode: state.skillWorkshopMode, - actionBusy: state.skillWorkshopActionBusy, - actionNotice: state.skillWorkshopActionNotice, - revisionKey: state.skillWorkshopRevisionKey, - revisionDraft: state.skillWorkshopRevisionDraft, - assistantName: state.assistantName, - counts, - onStatusFilterChange: (next) => (state.skillWorkshopStatusFilter = next), - onQueryChange: (next) => (state.skillWorkshopQuery = next), - onFilePreviewQueryChange: (next) => (state.skillWorkshopFilePreviewQuery = next), - onQueueWidthChange: (width) => setSkillWorkshopQueueWidth(state, width), - onQueueWidthCommit: (width) => - setSkillWorkshopQueueWidth(state, width, { persist: true }), - onModeChange: (mode) => setSkillWorkshopMode(state, mode), - onSelect: (key) => { - const proposal = proposals.find((p) => p.key === key); - selectSkillWorkshopProposal(state, key); - if (proposal) { - state.skillWorkshopReviewedKeys = rememberSkillWorkshopProposalReviewed( - state.skillWorkshopReviewedKeys, - proposal, - ); - } - state.skillWorkshopFilePreviewKey = null; - state.skillWorkshopFilePreviewQuery = ""; - }, - onPrev: () => goto(-1), - onNext: () => goto(1), - onApply: (key) => { - void runSkillWorkshopLifecycleAction(state, "apply", key); - }, - onRevise: (key) => { - state.skillWorkshopRevisionKey = key; - state.skillWorkshopRevisionDraft = ""; - state.skillWorkshopActionNotice = null; - }, - onReject: (key) => { - void runSkillWorkshopLifecycleAction(state, "reject", key); - }, - onRevisionDraftChange: (draft) => { - state.skillWorkshopRevisionDraft = draft; - }, - onRevisionCancel: () => { - if (state.skillWorkshopActionBusy) { - return; - } - state.skillWorkshopRevisionKey = null; - state.skillWorkshopRevisionDraft = ""; - }, - onRevisionSubmit: (key) => { - if (!state.skillWorkshopRevisionDraft.trim()) { - return; - } - const proposal = state.skillWorkshopProposals.find((p) => p.key === key); - if (proposal) { - startSkillWorkshopHandoffOverlay(state, { - key: proposal.key, - slug: proposal.slug, - }); - } - void (async () => { - const succeeded = await requestSkillWorkshopRevision(state, key, (message, p) => - sendSkillWorkshopRevisionRequest(state, message, p), - ); - if (succeeded) { - finishSkillWorkshopHandoffOverlay(state); - } else { - state.skillWorkshopRevisionKey = null; - failSkillWorkshopHandoffOverlay(state); - } - })(); - }, - onPreviewFile: (_key, path) => { - state.skillWorkshopFilePreviewKey = path; - void loadSkillWorkshopProposalDetail(state, _key); - }, - onClosePreview: () => { - state.skillWorkshopFilePreviewKey = null; - state.skillWorkshopFilePreviewQuery = ""; - }, - }); - }) - : nothing} ${state.tab === "nodes" ? renderLazyView(lazyNodes, (m) => m.renderNodes({ @@ -3592,7 +3116,7 @@ export function renderApp(state: AppViewState) { renderChat({ sessionKey: state.sessionKey, onSessionKeyChange: (next) => { - void switchChatSession(state, next); + switchChatSession(state, next); }, thinkingLevel: state.chatThinkingLevel, showThinking, @@ -3624,22 +3148,25 @@ export function renderApp(state: AppViewState) { runStatus: state.chatRunStatus, onDismissError: () => dismissChatError(state), sessions: state.sessionsResult, - focusMode: chatFocus, + composerControls: renderChatControls(state), + workspaceFiles: { + agentId: chatAgentId, + list: + chatWorkspaceFiles.list?.agentId === chatAgentId + ? chatWorkspaceFiles.list + : null, + loading: chatWorkspaceFiles.loading, + error: chatWorkspaceFiles.error, + activeName: chatWorkspaceFiles.activeName, + onRefresh: refreshChatWorkspaceFiles, + onOpenFile: openChatWorkspaceFile, + }, autoExpandToolCalls: false, onRefresh: () => { state.chatSideResult = null; state.resetToolStream(); void refreshChat(state, { awaitHistory: true, scheduleScroll: false }); }, - onToggleFocusMode: () => { - if (state.onboarding) { - return; - } - state.applySettings({ - ...state.settings, - chatFocusMode: !state.settings.chatFocusMode, - }); - }, onChatScroll: (event) => state.handleChatScroll(event), getDraft: () => state.chatMessage, onDraftChange: (next) => state.handleChatDraftChange(next), @@ -3704,17 +3231,17 @@ export function renderApp(state: AppViewState) { } }), agentsList: state.agentsList, - currentAgentId: resolvedAgentId ?? "main", + currentAgentId: chatAgentId, fullMessageAgentId: scopedAgentParamsForSession(state, state.sessionKey).agentId, onAgentChange: (agentId: string) => { - void switchChatSession(state, buildAgentMainSessionKey({ agentId })); + switchChatSession(state, buildAgentMainSessionKey({ agentId })); }, onNavigateToAgent: () => { state.agentsSelectedId = resolvedAgentId; state.setTab("agents" as import("./navigation.ts").Tab); }, onSessionSelect: (key: string) => { - void switchChatSession(state, key); + switchChatSession(state, key); }, showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight, onScrollToBottom: () => state.scrollToBottom(), @@ -3830,7 +3357,7 @@ export function renderApp(state: AppViewState) { onRefresh: refreshDreaming, onSelectAgent: (agentId: string) => { state.selectedAgentId = agentId; - void switchChatSession(state, resolvePreferredSessionForAgent(state, agentId)); + switchChatSession(state, resolvePreferredSessionForAgent(state, agentId)); void loadDreamingStatus(state); void loadDreamDiary(state); }, @@ -3880,7 +3407,6 @@ export function renderApp(state: AppViewState) { : nothing}
${renderExecApprovalPrompt(state)} ${renderGatewayUrlConfirmation(state)} - ${renderSkillWorkshopHandoffOverlay(state)} ${renderDreamingRestartConfirmation({ open: state.dreamingRestartConfirmOpen, loading: state.dreamingRestartConfirmLoading, diff --git a/ui/src/ui/app-scroll.test.ts b/ui/src/ui/app-scroll.test.ts index 505163bf1a84..03f76dfcfff3 100644 --- a/ui/src/ui/app-scroll.test.ts +++ b/ui/src/ui/app-scroll.test.ts @@ -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); - }); }); /* ------------------------------------------------------------------ */ diff --git a/ui/src/ui/app-scroll.ts b/ui/src/ui/app-scroll.ts index 90dbf12155db..b606f16eae58 100644 --- a/ui/src/ui/app-scroll.ts +++ b/ui/src/ui/app-scroll.ts @@ -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. diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index 3065c13262f9..25d038f03267 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -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, diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index c774f9033a2d..5ca08d325e1f 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -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; skillCardLoadingKey: string | null; skillCardErrors: Record; - 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; - skillWorkshopActionBusy: { key: string; action: "apply" | "revise" | "reject" } | null; - skillWorkshopActionNotice: { key: string; label: string; slug: string } | null; - skillWorkshopRevisionKey: string | null; - skillWorkshopRevisionDraft: string; - skillWorkshopActionNoticeTimer?: ReturnType | number | null; - skillWorkshopChatHandoffActive?: boolean; - skillWorkshopChatHandoffTimer?: ReturnType | number | null; - skillWorkshopHandoff: { - key: string; - slug: string; - phase: "prepare" | "landing" | "error"; - } | null; - skillWorkshopHandoffDismissTimer?: ReturnType | number | null; healthLoading: boolean; healthResult: HealthSummary | null; healthError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index d283ef14fa96..1467166db61b 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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 = {}; - @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 | number | null = null; - @state() skillWorkshopChatHandoffActive = false; - skillWorkshopChatHandoffTimer: ReturnType | number | null = null; - @state() skillWorkshopHandoff: { - key: string; - slug: string; - phase: "prepare" | "landing" | "error"; - } | null = null; - skillWorkshopHandoffDismissTimer: ReturnType | 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[0]); super.disconnectedCallback(); diff --git a/ui/src/ui/chat/chat-responsive.browser.test.ts b/ui/src/ui/chat/chat-responsive.browser.test.ts index 70cbcee24d21..030e9cd2f86c 100644 --- a/ui/src/ui/chat/chat-responsive.browser.test.ts +++ b/ui/src/ui/chat/chat-responsive.browser.test.ts @@ -99,12 +99,9 @@ function chatControlsHtml(opts: { agent?: boolean } = {}) { - - +
+ gpt-5 · High +
@@ -130,12 +127,9 @@ function chatHeaderControlsHtml(hidden = false) { - - +
+ gpt-5.5 · High +
@@ -144,7 +138,6 @@ function chatHeaderControlsHtml(hidden = false) { | -
@@ -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(); } diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 8075ee732241..93eeb0231098 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -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( diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 853d876189a2..af97824e28aa 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -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` +
+ ${renderChatAvatar( + group.role, + { + name: assistantName, + avatar: opts.assistantAvatar ?? null, + }, + { + name: opts.userName ?? null, + avatar: opts.userAvatar ?? null, + }, + opts.basePath, + opts.assistantAttachmentAuthToken, + )} +
+
+ + ${activityExpanded + ? html` +
+ ${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, + ), + )} +
+ ` + : nothing} +
+ +
+
+ `; + } + return html`
${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"; diff --git a/ui/src/ui/chat/run-controls.ts b/ui/src/ui/chat/run-controls.ts index 398f2142b4ca..c5d836dcbe0d 100644 --- a/ui/src/ui/chat/run-controls.ts +++ b/ui/src/ui/chat/run-controls.ts @@ -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`
- ${props.canAbort - ? nothing - : html` + ${showSecondary && !props.canAbort + ? html` - `} - - + ` + : nothing} + ${showSecondary + ? html` + + ` + : nothing} ${props.canAbort ? html`
${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`
@@ -542,6 +569,11 @@ function renderChatSessionPicker(params: { } }} > + ${compact + ? html`` + : ""} ${selectedSessionLabel}
` : nothing} - ${props.focusMode - ? html` - - ` - : nothing} ${renderSearchBar(requestUpdate)} ${renderPinnedSection(props, pinned, requestUpdate)} -
-
- ${thread} -
+
+
+
+ ${thread} +
- ${sidebarOpen - ? html` - props.onSplitRatioChange?.(e.detail.splitRatio)} - > -
- ${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); - } - }, - })} -
- ` - : nothing} + ${sidebarOpen + ? html` + props.onSplitRatioChange?.(e.detail.splitRatio)} + > +
+ ${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); + } + }, + })} +
+ ` + : nothing} +
+ ${renderWorkspaceFileRail(props.workspaceFiles)}
${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` + ` + : nothing} + ${props.onToggleRealtimeTalkOptions + ? html` ` : nothing} + ${props.composerControls + ? html`
${props.composerControls}
` + : nothing} ${tokens ? html`${tokens}` : nothing} ${renderChatRunStatusIndicator(composerRunStatus)}
@@ -1659,6 +1761,7 @@ export function renderChat(props: ChatProps) { onNewSession: props.onNewSession, onSend: props.onSend, onStoreDraft: () => {}, + showSecondary: false, })}
diff --git a/ui/src/ui/views/login-gate.test.ts b/ui/src/ui/views/login-gate.test.ts index fac4dc444390..5864f85d7ac9 100644 --- a/ui/src/ui/views/login-gate.test.ts +++ b/ui/src/ui/views/login-gate.test.ts @@ -23,7 +23,6 @@ function createState(overrides: Partial = {}): AppViewState { lastActiveSessionKey: "main", theme: "claw", themeMode: "system", - chatFocusMode: false, chatShowThinking: true, chatShowToolCalls: true, splitRatio: 0.6, diff --git a/ui/src/ui/views/overview.render.test.ts b/ui/src/ui/views/overview.render.test.ts index a886f8fc7476..9751381eb4f2 100644 --- a/ui/src/ui/views/overview.render.test.ts +++ b/ui/src/ui/views/overview.render.test.ts @@ -18,7 +18,6 @@ function createOverviewProps(overrides: Partial = {}): OverviewPr lastActiveSessionKey: "main", theme: "claw", themeMode: "system", - chatFocusMode: false, chatShowThinking: true, chatShowToolCalls: true, splitRatio: 0.6,