Compare commits

...

86 Commits

Author SHA1 Message Date
Simos Mikelatos
9bcb9436a1 ci: drop redundant desktop-macos-release workflow (consolidated on main)
Main consolidated per-OS desktop release workflows into desktop-release.yml
(d882f80). This standalone macOS workflow survived on the branch via a
modify/delete merge conflict and duplicates the build-macos job now in
desktop-release.yml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:03:39 +00:00
Simos Mikelatos
19e4bf9d80 chore: remove computer use (re-applied after main merge)
The merge of main into this branch (0907d87) hit a modify/delete conflict
for the computer-use module — main removed it in 6761f31 while this branch
had extended it — and the conflict was resolved by keeping the branch's
copies, silently resurrecting the whole feature.

This re-applies 6761f31's removal on top of the browser-use work:
- deletes the computer-use server module, client panel, settings tab,
  desktop-agent websocket service, electron computerAgent, and the
  computer-semantics build scripts
- strips computer-use wiring from shared files (server/index.js, electron
  launcher/main, MainContent, settings, i18n) while preserving browser-use
- removes src/constants/featureFlags.ts (existed only for the CU menu flag)

browser-use (camoufox/noVNC) is untouched. typecheck passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:54:22 +00:00
Haile
a34249497e Merge branch 'main' into camoufox-novnc-browser-use 2026-07-01 16:57:16 +03:00
Haile
7eb7348d50 Feat/design improvements and minor bug fixes (#939)
* fix(shell): hide prompt options on desktop

* fix(chat): group continuous same-tool runs more consistently

Consecutive tool calls (Edit, Read, Grep, etc.) grouped inconsistently:

- The group threshold was 3, so a run of only 2 calls stayed ungrouped
  while a run of 3 collapsed — making two back-to-back edits look
  different from three.
- A run was broken by any interleaved message, including ones that render
  nothing (reasoning hidden when showThinking is off). Providers like
  Codex interleave hidden reasoning between tool calls, so visually
  continuous edits intermittently failed to group.

Lower TOOL_GROUP_THRESHOLD to 2 and skip non-rendered messages when
extending a run, so any 2+ consecutive same-tool calls collapse reliably.
ChatMessagesPane now passes showThinking into groupConsecutiveTools.

* fix(chat): stabilize message scroll controls

* fix: update command menu positioning

* fix(chat): refine load all overlay behavior

* fix(chat): hide load all prompt after final page

* fix(chat): remove auto scroll quick setting

* fix(chat): unify messages and composer into centered column

Constrain both ChatMessagesPane content and ChatComposer to the same
max-w-3xl centered column. Previously only
the composer had a max-width, causing messages to fill the full width
while the input stayed narrow, making them visually misaligned with
large empty gutters on either side.

* style(ui): rework light/dark theme to make it visually consistent

Rework the color system around warm neutrals and route hardcoded
surfaces through theme tokens for consistency.

- Theme tokens (index.css, ThemeContext): warm cream light mode and
  neutral charcoal dark mode, replacing the pure-white/blue-tinted
  palette; update PWA theme-color meta
- Code blocks: soft grey background in light mode via
  oneLight/oneDark, and drop the Tailwind Typography <pre> shell that
  framed the highlighter in a dark box
- Dropdowns/panels: convert CommandMenu, Quick Settings, and the JSON
  response block from hardcoded gray/slate to popover/muted/border
  tokens
- Git panel: Publish button purple -> primary blue
- Composer: drop top padding so the input sits flush with the thread

* fix: use app theme for code editor

* style(chat): unify composer toolbar heights and declutter slash-command modal

- Composer: give the permission-mode and token-usage buttons a fixed
  h-8 so every bottom-toolbar control shares one height
- CommandResultModal: replace the blue gradient header (gradient fill,
  glow blobs, blue eyebrow + icon chip) with a clean neutral header on
  popover/muted tokens

* fix(chat): header ellipsis, Codex logo on light theme, portal copy menu

- MainContentTitle: truncate the session title with an ellipsis instead
  of horizontal-scrolling it
- MessageComponent: use text-foreground for the provider logo chip so the
  currentColor Codex/OpenAI mark is visible on the light theme
- MessageCopyControl: render the copy-format dropdown in a portal so it
  escapes the chat message's `contain: paint` clip box; anchor it to the
  trigger, flip above near the viewport bottom, close on scroll/resize

* style(mcp): remove purple accents and portal the server form modal

- Replace the purple provider-button colors, heading icon, and form
  submit button with the primary token (no purple in the MCP UI)
- Portal the add/edit MCP server modal to document.body so its fixed
  overlay covers the full viewport, fixing the white band at the top
  caused by the Settings dialog's transformed tab content becoming the
  containing block

* style(ui): use Merriweather serif for chat text and Encode Sans for the rest of the UI

* fix: align activity indicator with composer input width

Wrap ActivityIndicator in the same mx-auto max-w-3xl container as the
text input so the "Analyzing…" label and Stop button stay within the
input's boundaries instead of spanning the full window width.

* style: improve thinking and stop button placements

* style(auth): modernize login, setup, and onboarding screens

* fix(chat): correct invalid dark-mode hover on AskUserQuestion options

* fix: remove unnecessary auto expand tools

* fix: resolve coderabbit comments

* fix(chat): widen chat layout and sidebar titles

* fix(branding): update CloudCLI wordmark styling

---------

Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-07-01 13:57:03 +02:00
Simos Mikelatos
44aecbab68 Clarify desktop companion in README 2026-07-01 11:29:14 +00:00
Simos Mikelatos
18e98a780d Point desktop downloads to CloudCLI 2026-07-01 11:17:27 +00:00
Haile
2ebe64f218 fix: preview video on new tab (#933) 2026-06-29 15:36:31 +02:00
Simos Mikelatos
0907d873f6 Merge remote-tracking branch 'origin/main' into camoufox-novnc-browser-use
# Conflicts:
#	.github/workflows/release.yml
#	electron/desktopWindow.js
#	electron/launcher/launcher.js
#	electron/main.js
#	electron/preload.cjs
#	package.json
#	scripts/release/prepare-desktop-app.js
#	server/modules/websocket/services/websocket-server.service.ts
#	src/components/main-content/view/MainContent.tsx
#	src/components/main-content/view/subcomponents/MainContentTitle.tsx
2026-06-29 12:34:48 +00:00
Haile
b6cf33308d fix: resolve mobile shell issues (#923) 2026-06-29 14:19:01 +02:00
Simos Mikelatos
ec437072ad Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	server/index.js
2026-06-29 07:43:33 +00:00
Haile
54f4d8aa36 Chat & sidebar UX improvements (#929) 2026-06-29 09:27:04 +02:00
Simos Mikelatos
261690935f Merge pull request #891 from siteboon/electron-app 2026-06-29 09:07:57 +02:00
Simos Mikelatos
46ba8e56b4 Potential fix for pull request finding 'Useless assignment to local variable'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-06-26 16:14:44 +02:00
Simos Mikelatos
a0899a252e Merge branch 'main' into electron-app 2026-06-26 16:09:19 +02:00
Simos Mikelatos
fff89e6132 Merge branch 'main' into camoufox-novnc-browser-use 2026-06-26 16:07:22 +02:00
Simos Mikelatos
3bc2c777a3 fix: await desktop auth token lookup 2026-06-26 10:33:10 +00:00
Simos Mikelatos
63f3c3941d feat: add desktop notifications and skills updates 2026-06-26 10:25:47 +00:00
Simos Mikelatos
e6c6f89dda Merge branch 'main' into electron-app 2026-06-26 10:02:48 +02:00
Simos Mikelatos
8adcdaa0e5 Merge branch 'main' into camoufox-novnc-browser-use 2026-06-24 23:16:13 +02:00
Simos Mikelatos
6f712269e8 fix: remove invalid windows build options 2026-06-24 21:05:59 +00:00
Simos Mikelatos
52244404a3 chore: remove windows icon generator 2026-06-24 20:50:45 +00:00
Simos Mikelatos
8ad18f8587 fix: improve desktop chat performance 2026-06-24 20:49:24 +00:00
Simos Mikelatos
fe116a7138 ci: restore notarized macOS branch builds 2026-06-24 20:25:53 +00:00
Simos Mikelatos
490e66ebdb fix: stabilize desktop environment auth navigation 2026-06-24 20:09:41 +00:00
Simos Mikelatos
81eb966904 ci: skip notarization for macOS branch builds 2026-06-24 20:05:52 +00:00
Simos Mikelatos
0d68dc2cd0 fix: add Electron tab diagnostics 2026-06-24 20:00:45 +00:00
Simos Mikelatos
0610cc8333 fix: browser use set profile root folder 2026-06-24 19:21:52 +00:00
Simos Mikelatos
9457651fdd fix(browser-use): harden browser settings state 2026-06-24 15:36:25 +00:00
Simos Mikelatos
8c31ebcc63 feat(browser-use): add Camoufox noVNC session viewer 2026-06-24 14:39:41 +00:00
Simos Mikelatos
bb630ef739 fix: hide computer use menus 2026-06-20 01:50:02 +00:00
Simos Mikelatos
1c05fe0905 fix: stabilize cloud computer use mcp 2026-06-19 20:47:53 +00:00
Simos Mikelatos
077baee5f2 fix: authenticate desktop agent websocket 2026-06-19 15:52:49 +00:00
coderabbitai[bot]
f150fa6b09 fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-06-19 15:22:43 +00:00
Simos Mikelatos
9f8cee8919 fix: restore macos semantic helper cast 2026-06-19 15:05:47 +00:00
Simos Mikelatos
bb323fc566 fix: respect cloud computer use setting 2026-06-19 15:02:07 +00:00
Simos Mikelatos
5ef40be2d3 fix: macos release 2026-06-19 14:46:58 +00:00
Simos Mikelatos
cf4b28273e fix: compile macos semantic helper 2026-06-19 14:22:47 +00:00
Simos Mikelatos
f4c68942a5 fix: repair desktop launcher local view 2026-06-19 14:20:23 +00:00
Simos Mikelatos
4d70a2588c feat: improve Computer Use linking status 2026-06-19 13:47:16 +00:00
Simos Mikelatos
218e8e2e38 chore: update Codex SDK to latest 2026-06-19 13:12:53 +00:00
Simos Mikelatos
53c3c4c27a Fix long-running desktop resource leaks 2026-06-19 13:07:08 +00:00
Simos Mikelatos
901c6fc956 chore: simplify desktop release artifacts 2026-06-19 13:04:53 +00:00
Simos Mikelatos
278fe4f7b1 Fix semantic review issues and release action runtime 2026-06-19 12:46:40 +00:00
Simos Mikelatos
d7f4d4c342 Fix desktop release review findings 2026-06-19 12:29:46 +00:00
Simos Mikelatos
d1930fecdb fix: build semantic helpers on macos and windows 2026-06-19 12:17:32 +00:00
Simos Mikelatos
1726705459 feat: add CloudCLI computer use semantics, desktop helper packaging, and permission onboarding 2026-06-19 12:09:55 +00:00
Simos Mikelatos
a35200f340 Harden computer use MCP handling 2026-06-19 08:06:26 +00:00
Simos Mikelatos
06c9745489 Update src/i18n/locales/zh-CN/settings.json
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-19 09:56:21 +02:00
Simos Mikelatos
0dd22db2bb Update src/i18n/locales/zh-TW/settings.json
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-19 09:56:01 +02:00
Simos Mikelatos
e7aa72c41e Update src/i18n/locales/tr/settings.json
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-19 09:55:45 +02:00
Simos Mikelatos
9f24f80f33 Fix computer use session error status 2026-06-19 07:47:56 +00:00
Simos Mikelatos
25ab273b05 Publish branch server bundles for thin desktop builds 2026-06-19 07:08:19 +00:00
Simos Mikelatos
5be100ea1b Keep branch desktop artifacts thin 2026-06-19 06:49:28 +00:00
Simos Mikelatos
2af3d38afe Harden desktop workflows and computer use handling 2026-06-19 06:21:13 +00:00
Simos Mikelatos
531833bc87 Merge branch 'main' into electron-app 2026-06-19 08:19:36 +02:00
Simos Mikelatos
b2333e7d93 Fix launcher CodeQL unused helpers 2026-06-18 21:17:09 +00:00
Simos Mikelatos
f75ae385dd Add on-demand desktop server bundle 2026-06-18 21:08:29 +00:00
Simos Mikelatos
7786763dd1 Fix desktop settings modal behavior 2026-06-18 06:15:17 +00:00
Simos Mikelatos
1dbf545fd9 Authenticate ripgrep install in desktop workflows 2026-06-17 22:29:55 +00:00
Simos Mikelatos
ac37213269 Run desktop branch builds on electron app pushes 2026-06-17 22:26:47 +00:00
Simos Mikelatos
65fdc38f2e Add desktop app packaging and settings updates 2026-06-17 22:15:36 +00:00
Simos Mikelatos
6c2652aee6 Merge remote-tracking branch 'origin/main' into electron-app
# Conflicts:
#	package-lock.json
#	package.json
#	server/index.js
#	src/components/main-content/types/types.ts
#	src/components/main-content/view/MainContent.tsx
#	src/components/main-content/view/subcomponents/MainContentHeader.tsx
#	src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx
#	src/components/main-content/view/subcomponents/MainContentTitle.tsx
#	src/components/settings/hooks/useSettingsController.ts
#	src/components/settings/types/types.ts
#	src/components/settings/view/Settings.tsx
#	src/components/settings/view/SettingsSidebar.tsx
#	src/hooks/useProjectsState.ts
#	src/i18n/locales/de/common.json
#	src/i18n/locales/en/common.json
#	src/i18n/locales/en/settings.json
#	src/i18n/locales/it/common.json
#	src/i18n/locales/ja/common.json
#	src/i18n/locales/ko/common.json
#	src/i18n/locales/ru/common.json
#	src/i18n/locales/tr/common.json
#	src/i18n/locales/zh-CN/common.json
#	src/i18n/locales/zh-TW/common.json
#	src/types/app.ts
2026-06-17 20:18:09 +00:00
Simos Mikelatos
bf50d29c20 Merge remote-tracking branch 'origin/browser-use' into electron-app
# Conflicts:
#	src/i18n/locales/en/settings.json
2026-06-17 20:17:38 +00:00
Simos Mikelatos
ffc0cd7501 Improve Browser settings load and managed MCP display 2026-06-17 20:04:44 +00:00
Simos Mikelatos
59194d1502 Refine Browser naming and managed MCP UX
- Rename Browser Use surfaces to Browser
- Register Browser MCP under the new server name
- Mark CloudCLI-managed MCP servers read-only
- Adjust MCP stdio framing and sidebar footer sizing
2026-06-17 19:18:23 +00:00
Simos Mikelatos
7e6028b113 feat: add desktop computer use runtime 2026-06-17 19:01:15 +00:00
Simos Mikelatos
9881e5e366 feat(browser-use): improve mobile monitoring ux 2026-06-17 18:19:12 +00:00
Simos Mikelatos
496a895e8a feat(browser-use): refine monitoring panel ux 2026-06-17 17:39:55 +00:00
Simos Mikelatos
086df034b4 feat(browser-use): simplify agent session monitoring 2026-06-17 17:04:11 +00:00
Simos Mikelatos
fc71fc7d2b Merge branch 'pr889-fixes' into electron-app
# Conflicts:
#	server/index.js
2026-06-17 15:45:07 +00:00
Simos Mikelatos
a0d56429a7 fix browser use 2026-06-17 15:43:21 +00:00
Simos Mikelatos
6af4afe6f2 Merge branch 'main' into browser-use 2026-06-16 19:02:36 +02:00
Simos Mikelatos
7aeca52669 Merge branch 'browser-use' into electron-app
# Conflicts:
#	src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx
2026-06-16 06:51:35 +00:00
Simos Mikelatos
56532af33a feat: add browser use guide links 2026-06-15 21:22:49 +00:00
Simos Mikelatos
9438a365f2 feat: improve browser use session controls 2026-06-15 21:14:10 +00:00
Simos Mikelatos
e5c6e5e596 fix: hide browser use runtime mode 2026-06-15 20:20:44 +00:00
Simos Mikelatos
0426522406 feat: expose browser use to agents via MCP 2026-06-15 19:47:58 +00:00
Simos Mikelatos
6e7e2ff4c1 feat: make browser use opt-in 2026-06-15 18:12:27 +00:00
Simos Mikelatos
e6263dbd1f refactor: store browser use settings in database 2026-06-15 17:57:00 +00:00
Simos Mikelatos
260070bae0 feat: add browser use runtime setup settings 2026-06-15 17:52:27 +00:00
Simos Mikelatos
daac6e3fd3 ci: add macos desktop release workflow 2026-06-15 17:26:53 +00:00
Simos Mikelatos
861cfecbaa feat: add electron app support 2026-06-15 16:21:05 +00:00
Simos Mikelatos
a182765e10 Merge branch 'browser-use' into electron-app 2026-06-15 16:15:03 +00:00
Simos Mikelatos
828d1a2302 Merge remote-tracking branch 'origin/feat/unify-websocket-2' into browser-use-independent 2026-06-15 16:12:10 +00:00
Simos Mikelatos
d427004bd7 Merge browser use branch 2026-06-14 20:34:36 +00:00
Simos Mikelatos
243e6cecd5 Add browser use workspace panel 2026-06-14 20:34:16 +00:00
100 changed files with 3698 additions and 973 deletions

View File

@@ -74,12 +74,6 @@ The fastest way to get started — no local setup required. Get a fully managed,
**[Get started with CloudCLI Cloud](https://cloudcli.ai)** **[Get started with CloudCLI Cloud](https://cloudcli.ai)**
### Desktop App
Download the latest macOS or Windows desktop app from the **[GitHub Releases](https://github.com/siteboon/claudecodeui/releases)** page.
Use the desktop app to open CloudCLI Cloud environments, switch between local and remote workspaces, copy mobile/browser URLs, and keep Local CloudCLI available from your menu bar or tray. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you.
### Self-Hosted (Open source) ### Self-Hosted (Open source)
#### npm #### npm
@@ -111,6 +105,16 @@ npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options. Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options.
### Desktop Companion App
CloudCLI Desktop is an optional native companion for CloudCLI Cloud and Local CloudCLI. It ships from this repository's GitHub Releases and keeps CloudCLI available from your menu bar or tray.
- **[macOS](https://cloudcli.ai/download/macos)**
- **[Windows](https://cloudcli.ai/download/windows)**
- **[Download page](https://cloudcli.ai/download)** · **[GitHub Releases and checksums](https://github.com/siteboon/claudecodeui/releases)**
Use it to open CloudCLI Cloud environments, switch between local and remote workspaces, and copy mobile/browser URLs. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you.
--- ---
@@ -125,7 +129,8 @@ CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self
| **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required | | **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required |
| **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation | | **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
| **Machine needs to stay on** | Yes | Yes | No | | **Machine needs to stay on** | Yes | Yes | No |
| **Mobile access** | Any browser on your network | Any browser on your network | Any device, native app coming | | **Mobile access** | Any browser on your network | Any browser on your network | Any device |
| **Desktop companion** | Optional. Choose Local CloudCLI | Optional. Choose Local CloudCLI | Optional. Opens cloud environments |
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | | **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
| **File explorer and Git** | Yes | Yes | Yes | | **File explorer and Git** | Yes | Yes | Yes |
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI | | **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |

View File

@@ -4,9 +4,17 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<title>CloudCLI UI</title> <title>CloudCLI UI</title>
<!-- Fonts: Encode Sans (UI) + Merriweather (chat) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Encode+Sans:wght@400;500;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400;1,700&display=swap"
rel="stylesheet"
/>
<!-- PWA Manifest --> <!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" /> <link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />

View File

@@ -69,7 +69,7 @@ const sessionIdSchema = {
const tools: ToolDefinition[] = [ const tools: ToolDefinition[] = [
{ {
name: 'browser_create_session', name: 'browser_create_session',
description: 'Create a temporary Browser session that the agent can control. Optionally provide a background profileName to reuse cookies and storage.', description: 'Create a Browser session that the agent can control. Provide profileName to use a specific persistent profile; when omitted, the configured persistent profile is used only if session persistence is enabled, otherwise a temporary session is created.',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {

View File

@@ -65,7 +65,7 @@ import providerRoutes from './modules/providers/provider.routes.js';
import voiceRoutes from './voice-proxy.js'; import voiceRoutes from './voice-proxy.js';
import browserUseRoutes from './modules/browser-use/browser-use.routes.js'; import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js'; import browserUseMcpRoutes from './modules/browser-use/browser-use-mcp.routes.js';
import { browserUseService } from './modules/browser-use/browser-use.service.js'; import { browserUseService, VIEWER_COOKIE_NAME } from './modules/browser-use/index.js';
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js'; import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js'; import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
import { configureWebPush } from './services/vapid-keys.js'; import { configureWebPush } from './services/vapid-keys.js';
@@ -147,6 +147,8 @@ const wss = createWebSocketServer(server, {
shouldAutoOpenUrlFromOutput, shouldAutoOpenUrlFromOutput,
}, },
getPluginPort, getPluginPort,
browserUseViewer: (ws, pathname) => browserUseService.handleViewerWebSocket(ws, pathname),
authenticateBrowserUseViewer: authenticateBrowserUseViewerPath,
}); });
// Make WebSocket server available to routes // Make WebSocket server available to routes
@@ -214,11 +216,42 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
// Plugins API Routes (protected) // Plugins API Routes (protected)
app.use('/api/plugins', authenticateToken, pluginsRoutes); app.use('/api/plugins', authenticateToken, pluginsRoutes);
function readCookieValue(header, name) {
if (!header) return null;
const prefix = `${name}=`;
const cookie = String(header).split(';').map((part) => part.trim()).find((part) => part.startsWith(prefix));
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null;
}
function authenticateBrowserUseViewerPath(pathname, token) {
const parts = String(pathname || '').split('/');
const sessionId = parts[4];
if (parts[1] !== 'api' || parts[2] !== 'browser-use' || parts[3] !== 'sessions' || parts[5] !== 'viewer' || parts[6] !== 'websockify') {
return false;
}
return browserUseService.validateViewerToken(decodeURIComponent(sessionId), token);
}
function authenticateBrowserUse(req, res, next) {
const match = /^\/sessions\/([^/]+)\/viewer(?:\/|$)/.exec(req.path || '');
if (match) {
const sessionId = decodeURIComponent(match[1]);
const token = typeof req.query.viewerToken === 'string'
? req.query.viewerToken
: readCookieValue(req.headers.cookie, VIEWER_COOKIE_NAME);
if (browserUseService.validateViewerToken(sessionId, token)) {
return next();
}
return res.status(401).json({ error: 'Browser viewer access requires a valid session token.' });
}
return authenticateToken(req, res, next);
}
// Browser MCP bridge API (local token protected) // Browser MCP bridge API (local token protected)
app.use('/api/browser-use-mcp', browserUseMcpRoutes); app.use('/api/browser-use-mcp', browserUseMcpRoutes);
// Browser API Routes (protected) // Browser API Routes (protected)
app.use('/api/browser-use', authenticateToken, browserUseRoutes); app.use('/api/browser-use', authenticateBrowserUse, browserUseRoutes);
// Unified provider MCP routes (protected) // Unified provider MCP routes (protected)
app.use('/api/providers', authenticateToken, providerRoutes); app.use('/api/providers', authenticateToken, providerRoutes);

View File

@@ -1,6 +1,7 @@
import express from 'express'; import express from 'express';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js'; import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
import { VIEWER_COOKIE_NAME, VIEWER_TOKEN_TTL_MS } from '@/modules/browser-use/browser-use.viewer.js';
const router = express.Router(); const router = express.Router();
@@ -8,6 +9,45 @@ function readParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || '' : value || ''; return Array.isArray(value) ? value[0] || '' : value || '';
} }
const SAFE_VIEWER_ROOT_FILES = new Set(['vnc.html', 'favicon.ico', 'manifest.json']);
const SAFE_VIEWER_ROOT_DIRS = new Set(['app', 'core', 'vendor', 'assets', 'images', 'utils']);
function isSafeViewerPath(viewerPath: string): boolean {
if (!viewerPath || viewerPath.startsWith('/') || viewerPath.includes('..') || viewerPath.includes('\\')) {
return false;
}
if (!/^[A-Za-z0-9][A-Za-z0-9._~/-]*$/.test(viewerPath)) {
return false;
}
if (SAFE_VIEWER_ROOT_FILES.has(viewerPath)) {
return true;
}
const [rootDir] = viewerPath.split('/');
return Boolean(rootDir && SAFE_VIEWER_ROOT_DIRS.has(rootDir));
}
function isSecureRequest(req: express.Request): boolean {
const forwardedProto = String(req.headers['x-forwarded-proto'] || '')
.split(',')[0]
.trim()
.toLowerCase();
return req.secure || forwardedProto === 'https';
}
function readQueryString(originalUrl: string): string {
const queryIndex = originalUrl.indexOf('?');
if (queryIndex < 0) {
return '';
}
const params = new URLSearchParams(originalUrl.slice(queryIndex + 1));
params.delete('viewerToken');
const nextQuery = params.toString();
return nextQuery ? `?${nextQuery}` : '';
}
router.get('/status', async (_req, res) => { router.get('/status', async (_req, res) => {
try { try {
res.json({ success: true, data: await browserUseService.getStatus() }); res.json({ success: true, data: await browserUseService.getStatus() });
@@ -62,13 +102,60 @@ router.get('/sessions', async (_req, res) => {
try { try {
res.json({ success: true, data: { sessions: await browserUseService.listSessions() } }); res.json({ success: true, data: { sessions: await browserUseService.listSessions() } });
} catch (error) { } catch (error) {
res.status(401).json({ res.status(500).json({
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to list browser sessions.', error: error instanceof Error ? error.message : 'Failed to list browser sessions.',
}); });
} }
}); });
router.get('/sessions/:sessionId/viewer/*', async (req, res) => {
try {
const sessionId = readParam(req.params.sessionId);
const originalPath = req.originalUrl.split('?')[0] || '';
const viewerMarker = `/sessions/${sessionId}/viewer/`;
const markerIndex = originalPath.indexOf(viewerMarker);
const rawViewerPath = markerIndex >= 0 ? originalPath.slice(markerIndex + viewerMarker.length) : 'vnc.html';
const viewerPath = decodeURIComponent(rawViewerPath).replace(/^\/+/, '') || 'vnc.html';
if (!isSafeViewerPath(viewerPath)) {
res.status(400).json({ success: false, error: 'Invalid Browser viewer path.' });
return;
}
const viewerToken = readParam(req.query.viewerToken as string | string[] | undefined);
if (viewerPath === 'vnc.html' && browserUseService.validateViewerToken(sessionId, viewerToken)) {
res.cookie(VIEWER_COOKIE_NAME, viewerToken, {
httpOnly: true,
sameSite: 'lax',
secure: isSecureRequest(req),
maxAge: VIEWER_TOKEN_TTL_MS,
path: '/api/browser-use/sessions/' + encodeURIComponent(sessionId) + '/viewer',
});
}
const target = browserUseService.getViewerProxyTarget(sessionId);
const query = readQueryString(req.originalUrl);
const upstream = await fetch(`http://127.0.0.1:${target.websockifyPort}/${viewerPath}${query}`, {
headers: {
accept: String(req.headers.accept || '*/*'),
},
});
const contentType = upstream.headers.get('content-type');
if (contentType) {
res.setHeader('content-type', contentType);
}
const cacheControl = viewerPath === 'vnc.html' ? 'no-store' : 'public, max-age=3600';
res.setHeader('cache-control', cacheControl);
res.status(upstream.status);
const body = Buffer.from(await upstream.arrayBuffer());
res.send(body);
} catch (error) {
res.status(404).json({
success: false,
error: error instanceof Error ? error.message : 'Browser viewer is not available.',
});
}
});
router.post('/sessions/:sessionId/stop', async (req, res) => { router.post('/sessions/:sessionId/stop', async (req, res) => {
try { try {
const result = await browserUseService.stopSession(readParam(req.params.sessionId)); const result = await browserUseService.stopSession(readParam(req.params.sessionId));

View File

@@ -1,128 +1,86 @@
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process'; import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { appConfigDb } from '@/modules/database/index.js'; import { WebSocket } from 'ws';
import { providerMcpService } from '@/modules/providers/index.js'; import { providerMcpService } from '@/modules/providers/index.js';
import { getModuleDir } from '@/utils/runtime-paths.js'; import { getModuleDir } from '@/utils/runtime-paths.js';
import {
getOrCreateMcpToken,
getProfilePath,
normalizeBrowserBackend,
PROFILE_ROOT,
readSettings,
resolveSessionProfileName,
useVisibleCamoufoxBackend,
writeSettings,
} from './browser-use.settings.js';
import type {
BrowserUseSession,
BrowserUseSettings,
PublicBrowserUseSession,
RuntimeHandle,
RuntimeProbe,
RuntimeReadiness,
} from './browser-use.types.js';
import { getViewerUrl, handleViewerWebSocket, VIEWER_TOKEN_TTL_MS } from './browser-use.viewer.js';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const __dirname = getModuleDir(import.meta.url); const __dirname = getModuleDir(import.meta.url);
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true'; const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10); const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10); const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
type BrowserUseRuntime = 'cloud' | 'local';
type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
type BrowserUseSession = {
id: string;
ownerId: string;
createdBy: 'agent';
runtime: BrowserUseRuntime;
status: BrowserUseSessionStatus;
url: string | null;
title: string | null;
screenshotDataUrl: string | null;
createdAt: string;
updatedAt: string;
lastAction: string | null;
message: string | null;
profileName: string | null;
viewport: {
width: number;
height: number;
} | null;
cursor: {
x: number;
y: number;
actor: 'agent';
} | null;
};
type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
type RuntimeHandle = {
browser?: any;
context?: any;
page?: any;
};
type BrowserUseSettings = {
enabled: boolean;
};
type RuntimeReadiness = {
playwright: any | null;
playwrightInstalled: boolean;
chromiumInstalled: boolean;
chromiumExecutablePath: string | null;
installInProgress: boolean;
installMessage: string | null;
};
type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;
const sessions = new Map<string, BrowserUseSession>(); const sessions = new Map<string, BrowserUseSession>();
const handles = new Map<string, RuntimeHandle>(); const handles = new Map<string, RuntimeHandle>();
const reservedDisplays = new Set<string>();
const viewerTokens = new Map<string, { token: string; expiresAt: number }>();
let installPromise: Promise<{ success: boolean; message: string }> | null = null; let installPromise: Promise<{ success: boolean; message: string }> | null = null;
let lastInstallMessage: string | null = null; let lastInstallMessage: string | null = null;
let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null; let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null;
const DEFAULT_SETTINGS: BrowserUseSettings = {
enabled: false,
};
const AGENT_OWNER_ID = 'agent'; const AGENT_OWNER_ID = 'agent';
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
const MCP_SERVER_NAME = 'cloudcli-browser'; const MCP_SERVER_NAME = 'cloudcli-browser';
const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use']; const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
const RUNTIME_READINESS_CACHE_TTL_MS = 30_000; const RUNTIME_READINESS_CACHE_TTL_MS = 30_000;
const VISIBLE_BROWSER_ENABLED = process.env.CLOUDCLI_BROWSER_USE_VISIBLE !== 'false';
const RUNTIME_ROOT = process.env.CLOUDCLI_BROWSER_USE_RUNTIME_ROOT || '/opt/claudecodeui/.runtime-browser';
const NOVNC_ROOT = process.env.CLOUDCLI_BROWSER_USE_NOVNC_ROOT || path.join(RUNTIME_ROOT, 'novnc');
const X11VNC_BIN = process.env.CLOUDCLI_BROWSER_USE_X11VNC_BIN || path.join(RUNTIME_ROOT, 'rootfs/usr/bin/x11vnc');
const X11VNC_LIB_DIR = process.env.CLOUDCLI_BROWSER_USE_X11VNC_LIB_DIR || path.join(RUNTIME_ROOT, 'rootfs/usr/lib/x86_64-linux-gnu');
const X11VNC_EXTRA_LIB_DIR = process.env.CLOUDCLI_BROWSER_USE_X11VNC_EXTRA_LIB_DIR || path.join(RUNTIME_ROOT, 'rootfs/lib/x86_64-linux-gnu');
const LOG_RUNTIME_PROCESS_OUTPUT = process.env.CLOUDCLI_BROWSER_USE_RUNTIME_LOGS === 'true';
function getRuntime(): BrowserUseRuntime { function getRuntime(): 'cloud' | 'local' {
return IS_PLATFORM ? 'cloud' : 'local'; return IS_PLATFORM ? 'cloud' : 'local';
} }
function readSettings(): BrowserUseSettings { function getCamoufoxExecutablePath(): string | null {
const configured = process.env.CLOUDCLI_BROWSER_USE_CAMOUFOX_EXECUTABLE;
if (configured && fs.existsSync(configured)) {
return configured;
}
try { try {
const raw = appConfigDb.get(BROWSER_USE_SETTINGS_KEY); const output = execFileSync(path.join(os.homedir(), '.local/bin/camoufox'), ['path'], {
if (!raw) { encoding: 'utf8',
return DEFAULT_SETTINGS; stdio: ['ignore', 'pipe', 'ignore'],
} }).trim();
const executablePath = fs.statSync(output).isDirectory()
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>; ? path.join(output, 'camoufox')
return { : output;
enabled: parsed.enabled === true, return fs.existsSync(executablePath) ? executablePath : null;
}; } catch {
} catch (error: any) { return null;
console.warn('[Browser] Failed to read settings:', error?.message || error);
return DEFAULT_SETTINGS;
} }
} }
function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
const normalized = {
enabled: settings.enabled === true,
};
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
return normalized;
}
function getOrCreateMcpToken(): string {
const existing = appConfigDb.get(BROWSER_USE_MCP_TOKEN_KEY);
if (existing) {
return existing;
}
const token = randomBytes(32).toString('hex');
appConfigDb.set(BROWSER_USE_MCP_TOKEN_KEY, token);
return token;
}
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string { function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
if (!settings.enabled) { if (!settings.enabled) {
return 'Browser is disabled in settings.'; return 'Browser is disabled in settings.';
@@ -132,6 +90,26 @@ function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadine
return 'Install Playwright and Chromium to use browser sessions.'; return 'Install Playwright and Chromium to use browser sessions.';
} }
if (settings.browserBackend === 'camoufox-vnc' && !getCamoufoxExecutablePath()) {
return 'Camoufox is selected, but Camoufox is not installed.';
}
if (useVisibleCamoufoxBackend(settings)) {
if (!VISIBLE_BROWSER_ENABLED) {
return 'Camoufox is selected, but visible browser sessions are disabled.';
}
if (!getCamoufoxExecutablePath()) {
return 'Camoufox is selected, but Camoufox is not installed.';
}
if (!fs.existsSync(X11VNC_BIN)) {
return 'Camoufox is selected, but x11vnc is missing.';
}
if (!fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html'))) {
return 'Camoufox is selected, but noVNC is missing.';
}
return readiness.installMessage || 'Camoufox runtime is not ready.';
}
if (!readiness.chromiumInstalled) { if (!readiness.chromiumInstalled) {
return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.'; return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.';
} }
@@ -176,24 +154,6 @@ async function removeMcpServerFromAllProviders(name: string) {
return results.map((result) => ({ ...result, name })); return results.map((result) => ({ ...result, name }));
} }
function normalizeProfileName(profileName?: string | null): string | null {
const normalized = String(profileName || '').trim();
if (!normalized) {
return null;
}
return normalized.slice(0, 80);
}
function getProfilePath(profileName: string): string {
const safeName = profileName
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'default';
return path.join(PROFILE_ROOT, safeName);
}
function probeRuntime(): RuntimeProbe { function probeRuntime(): RuntimeProbe {
const playwright = getPlaywright(); const playwright = getPlaywright();
const readiness: RuntimeProbe = { const readiness: RuntimeProbe = {
@@ -238,6 +198,175 @@ function getRuntimeReadiness(options: { force?: boolean } = {}): RuntimeReadines
}; };
} }
function findAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
server.close(() => {
if (typeof address === 'object' && address?.port) {
resolve(address.port);
} else {
reject(new Error('Failed to reserve a browser runtime port.'));
}
});
});
});
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRuntimeProcessAlive(child: ReturnType<typeof spawn>): boolean {
return child.exitCode === null && child.signalCode === null && !child.killed;
}
function assertRuntimeProcessesAlive(processes: Array<ReturnType<typeof spawn>>, label: string) {
const exited = processes.find((child) => !isRuntimeProcessAlive(child));
if (exited) {
throw new Error(`${label} exited before the Browser viewer runtime was ready.`);
}
}
async function isPortListening(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.createConnection({ host: '127.0.0.1', port });
let settled = false;
const finish = (listening: boolean) => {
if (settled) {
return;
}
settled = true;
socket.destroy();
resolve(listening);
};
socket.setTimeout(250);
socket.once('connect', () => finish(true));
socket.once('timeout', () => finish(false));
socket.once('error', () => finish(false));
});
}
async function waitForRuntimePort(
port: number,
label: string,
processes: Array<ReturnType<typeof spawn>>,
timeoutMs = 5_000,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
assertRuntimeProcessesAlive(processes, label);
if (await isPortListening(port)) {
return;
}
await delay(100);
}
assertRuntimeProcessesAlive(processes, label);
throw new Error(`${label} did not start listening on 127.0.0.1:${port}.`);
}
function killRuntimeProcesses(processes?: Array<ReturnType<typeof spawn>>) {
processes?.forEach((child) => child.kill('SIGTERM'));
}
function reserveDisplay(): string {
for (let index = 90; index < 140; index += 1) {
const display = `:${index}`;
if (!reservedDisplays.has(display)) {
reservedDisplays.add(display);
return display;
}
}
throw new Error('No browser display slots are available.');
}
function spawnRuntimeProcess(command: string, args: string[], options: { env?: NodeJS.ProcessEnv } = {}) {
const child = spawn(command, args, {
env: { ...process.env, ...options.env },
stdio: ['ignore', 'ignore', 'pipe'],
});
child.stderr?.on('data', (chunk) => {
if (!LOG_RUNTIME_PROCESS_OUTPUT) {
return;
}
const text = String(chunk).trim();
if (text) {
console.warn(`[Browser runtime] ${path.basename(command)}: ${text}`);
}
});
child.on('error', (error) => {
console.warn(`[Browser runtime] ${path.basename(command)} failed:`, error.message);
});
return child;
}
async function startVisibleRuntime(): Promise<NonNullable<RuntimeHandle['viewer']> & { processes: Array<ReturnType<typeof spawn>> }> {
const display = reserveDisplay();
const vncPort = await findAvailablePort();
const websockifyPort = await findAvailablePort();
const processes: Array<ReturnType<typeof spawn>> = [];
try {
processes.push(spawnRuntimeProcess('Xvfb', [
display,
'-screen',
'0',
'1440x900x24',
'-ac',
'-nolisten',
'tcp',
]));
await delay(700);
assertRuntimeProcessesAlive(processes, 'Xvfb');
if (!fs.existsSync(X11VNC_BIN)) {
throw new Error(`x11vnc is missing at ${X11VNC_BIN}.`);
}
processes.push(spawnRuntimeProcess(X11VNC_BIN, [
'-display',
display,
'-localhost',
'-forever',
'-shared',
'-rfbport',
String(vncPort),
'-nopw',
'-quiet',
], {
env: {
LD_LIBRARY_PATH: `${X11VNC_LIB_DIR}:${X11VNC_EXTRA_LIB_DIR}:${process.env.LD_LIBRARY_PATH || ''}`,
},
}));
await waitForRuntimePort(vncPort, 'x11vnc', processes);
if (!fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html'))) {
throw new Error(`noVNC is missing at ${NOVNC_ROOT}.`);
}
processes.push(spawnRuntimeProcess(path.join(os.homedir(), '.local/bin/websockify'), [
'--web',
NOVNC_ROOT,
`127.0.0.1:${websockifyPort}`,
`127.0.0.1:${vncPort}`,
]));
await waitForRuntimePort(websockifyPort, 'websockify', processes);
return {
display,
vncPort,
websockifyPort,
noVncRoot: NOVNC_ROOT,
processes,
};
} catch (error) {
killRuntimeProcesses(processes);
reservedDisplays.delete(display);
throw error;
}
}
const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt( const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000), process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
10, 10,
@@ -350,6 +479,45 @@ function publicSession(session: BrowserUseSession): PublicBrowserUseSession {
return publicFields; return publicFields;
} }
function getSessionViewer(sessionId: string): RuntimeHandle['viewer'] | null {
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID || session.status !== 'ready') {
return null;
}
return handles.get(sessionId)?.viewer || null;
}
function createViewerToken(sessionId: string): string {
const token = randomUUID();
viewerTokens.set(sessionId, {
token,
expiresAt: Date.now() + VIEWER_TOKEN_TTL_MS,
});
return token;
}
function deleteViewerToken(sessionId: string) {
viewerTokens.delete(sessionId);
}
function validateViewerTokenForSession(sessionId: string, token: string | null | undefined): boolean {
if (!token) {
return false;
}
const session = sessions.get(sessionId);
const viewer = session?.ownerId === AGENT_OWNER_ID && session.status === 'ready'
? handles.get(sessionId)?.viewer || null
: null;
const stored = viewerTokens.get(sessionId);
if (!viewer || !stored || stored.token !== token || stored.expiresAt < Date.now()) {
if (stored?.expiresAt && stored.expiresAt < Date.now()) {
viewerTokens.delete(sessionId);
}
return false;
}
return true;
}
function ownerSessions(ownerId: string): BrowserUseSession[] { function ownerSessions(ownerId: string): BrowserUseSession[] {
return [...sessions.values()].filter((session) => session.ownerId === ownerId); return [...sessions.values()].filter((session) => session.ownerId === ownerId);
} }
@@ -357,8 +525,13 @@ function ownerSessions(ownerId: string): BrowserUseSession[] {
async function closeHandle(sessionId: string): Promise<void> { async function closeHandle(sessionId: string): Promise<void> {
const handle = handles.get(sessionId); const handle = handles.get(sessionId);
handles.delete(sessionId); handles.delete(sessionId);
deleteViewerToken(sessionId);
await handle?.context?.close?.().catch(() => undefined); await handle?.context?.close?.().catch(() => undefined);
await handle?.browser?.close().catch(() => undefined); await handle?.browser?.close().catch(() => undefined);
killRuntimeProcesses(handle?.processes);
if (handle?.viewer?.display) {
reservedDisplays.delete(handle.viewer.display);
}
} }
async function expireStaleSessions(now = Date.now()): Promise<void> { async function expireStaleSessions(now = Date.now()): Promise<void> {
@@ -424,6 +597,11 @@ export const browserUseService = {
const current = readSettings(); const current = readSettings();
const nextSettings = { const nextSettings = {
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled, enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled,
persistSessions: typeof settings.persistSessions === 'boolean' ? settings.persistSessions : current.persistSessions,
defaultProfileName: typeof settings.defaultProfileName === 'string'
? settings.defaultProfileName
: current.defaultProfileName,
browserBackend: settings.browserBackend ? normalizeBrowserBackend(settings.browserBackend) : current.browserBackend,
}; };
const next = writeSettings(nextSettings); const next = writeSettings(nextSettings);
@@ -439,14 +617,28 @@ export const browserUseService = {
async getStatus() { async getStatus() {
const settings = readSettings(); const settings = readSettings();
const readiness = getRuntimeReadiness(); const readiness = getRuntimeReadiness();
const available = settings.enabled && readiness.playwrightInstalled && readiness.chromiumInstalled; const useVisibleBackend = useVisibleCamoufoxBackend(settings);
const visibleCamoufoxReady = useVisibleBackend
&& VISIBLE_BROWSER_ENABLED
&& readiness.playwrightInstalled
&& Boolean(getCamoufoxExecutablePath())
&& fs.existsSync(X11VNC_BIN)
&& fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html'));
const available = settings.enabled
&& readiness.playwrightInstalled
&& (useVisibleBackend ? visibleCamoufoxReady : readiness.chromiumInstalled);
return { return {
enabled: settings.enabled, enabled: settings.enabled,
runtime: getRuntime(), runtime: getRuntime(),
backend: useVisibleBackend ? 'camoufox-vnc' : 'playwright',
browserBackend: settings.browserBackend,
available, available,
playwrightInstalled: readiness.playwrightInstalled, playwrightInstalled: readiness.playwrightInstalled,
chromiumInstalled: readiness.chromiumInstalled, chromiumInstalled: readiness.chromiumInstalled,
camoufoxInstalled: Boolean(getCamoufoxExecutablePath()),
noVncInstalled: fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html')),
x11vncInstalled: fs.existsSync(X11VNC_BIN),
installInProgress: readiness.installInProgress, installInProgress: readiness.installInProgress,
sessionCount: sessions.size, sessionCount: sessions.size,
message: available message: available
@@ -505,7 +697,7 @@ export const browserUseService = {
} }
await expireStaleSessions(); await expireStaleSessions();
const profileName = normalizeProfileName(options?.profileName); const profileName = resolveSessionProfileName(settings, options?.profileName);
const now = new Date().toISOString(); const now = new Date().toISOString();
const session: BrowserUseSession = { const session: BrowserUseSession = {
@@ -521,6 +713,9 @@ export const browserUseService = {
updatedAt: now, updatedAt: now,
lastAction: 'create', lastAction: 'create',
message: null, message: null,
backend: useVisibleCamoufoxBackend(settings) ? 'camoufox-vnc' : 'playwright',
viewerUrl: null,
viewerEmbedUrl: null,
profileName, profileName,
viewport: { width: 1440, height: 900 }, viewport: { width: 1440, height: 900 },
cursor: null, cursor: null,
@@ -532,7 +727,13 @@ export const browserUseService = {
} }
const readiness = getRuntimeReadiness(); const readiness = getRuntimeReadiness();
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) { const useVisibleBackend = useVisibleCamoufoxBackend(settings);
const visibleCamoufoxReady = useVisibleBackend
&& VISIBLE_BROWSER_ENABLED
&& Boolean(getCamoufoxExecutablePath())
&& fs.existsSync(X11VNC_BIN)
&& fs.existsSync(path.join(NOVNC_ROOT, 'vnc.html'));
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.playwright || (useVisibleBackend ? !visibleCamoufoxReady : !readiness.chromiumInstalled)) {
session.message = getSetupMessage(settings, readiness); session.message = getSetupMessage(settings, readiness);
sessions.set(session.id, session); sessions.set(session.id, session);
return publicSession(session); return publicSession(session);
@@ -541,31 +742,73 @@ export const browserUseService = {
let browser: any | undefined; let browser: any | undefined;
let context: any | undefined; let context: any | undefined;
let page: any; let page: any;
const launchOptions = { let viewer: RuntimeHandle['viewer'];
headless: true, let processes: RuntimeHandle['processes'];
const launchOptions: Record<string, unknown> = {
headless: !useVisibleBackend,
args: ['--disable-dev-shm-usage'], args: ['--disable-dev-shm-usage'],
}; };
const contextOptions = { const contextOptions = useVisibleBackend
viewport: { width: 1440, height: 900 }, ? { viewport: null }
serviceWorkers: 'block', : {
}; viewport: { width: 1440, height: 900 },
serviceWorkers: 'block',
};
if (profileName) { try {
fs.mkdirSync(PROFILE_ROOT, { recursive: true }); if (useVisibleBackend) {
context = await readiness.playwright.chromium.launchPersistentContext(getProfilePath(profileName), { const camoufoxExecutable = getCamoufoxExecutablePath();
...launchOptions, if (!camoufoxExecutable) {
...contextOptions, throw new Error('Camoufox is not installed.');
}); }
page = context.pages()[0] || await context.newPage(); const runtime = await startVisibleRuntime();
} else { viewer = {
browser = await readiness.playwright.chromium.launch(launchOptions); display: runtime.display,
context = await browser.newContext(contextOptions); vncPort: runtime.vncPort,
page = await context.newPage(); websockifyPort: runtime.websockifyPort,
noVncRoot: runtime.noVncRoot,
};
processes = runtime.processes;
launchOptions.executablePath = camoufoxExecutable;
launchOptions.env = {
...process.env,
DISPLAY: runtime.display,
LD_LIBRARY_PATH: `${X11VNC_LIB_DIR}:${X11VNC_EXTRA_LIB_DIR}:${process.env.LD_LIBRARY_PATH || ''}`,
};
launchOptions.args = [];
session.backend = 'camoufox-vnc';
const viewerToken = createViewerToken(session.id);
session.viewerUrl = getViewerUrl(session.id, viewerToken);
session.viewerEmbedUrl = session.viewerUrl;
}
if (profileName) {
fs.mkdirSync(PROFILE_ROOT, { recursive: true });
const browserType = useVisibleBackend ? readiness.playwright.firefox : readiness.playwright.chromium;
context = await browserType.launchPersistentContext(getProfilePath(profileName), {
...launchOptions,
...contextOptions,
});
page = context.pages()[0] || await context.newPage();
} else {
const browserType = useVisibleBackend ? readiness.playwright.firefox : readiness.playwright.chromium;
browser = await browserType.launch(launchOptions);
context = await browser.newContext(contextOptions);
page = await context.newPage();
}
} catch (error) {
await context?.close?.().catch(() => undefined);
await browser?.close?.().catch(() => undefined);
killRuntimeProcesses(processes);
if (viewer?.display) {
reservedDisplays.delete(viewer.display);
}
throw error;
} }
session.status = 'ready'; session.status = 'ready';
session.message = 'Browser session is ready.'; session.message = 'Browser session is ready.';
sessions.set(session.id, session); sessions.set(session.id, session);
handles.set(session.id, { browser, context, page }); handles.set(session.id, { browser, context, page, processes, viewer });
await captureSession(session, page); await captureSession(session, page);
return publicSession(session); return publicSession(session);
}, },
@@ -812,6 +1055,25 @@ export const browserUseService = {
return { deleted: true, sessionId }; return { deleted: true, sessionId };
}, },
getViewerProxyTarget(sessionId: string) {
const viewer = getSessionViewer(sessionId);
if (!viewer) {
throw new Error('Browser viewer is not available for this session.');
}
return {
websockifyPort: viewer.websockifyPort,
noVncRoot: viewer.noVncRoot,
};
},
validateViewerToken(sessionId: string, token: string | null | undefined) {
return validateViewerTokenForSession(sessionId, token);
},
handleViewerWebSocket(clientWs: WebSocket, pathname: string) {
handleViewerWebSocket(clientWs, pathname, getSessionViewer);
},
async agentStopSession(sessionId: string) { async agentStopSession(sessionId: string) {
await this.getAgentSession(sessionId); await this.getAgentSession(sessionId);
return this.stopSession(sessionId); return this.stopSession(sessionId);

View File

@@ -0,0 +1,147 @@
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { appConfigDb } from '@/modules/database/index.js';
import type { BrowserUseBackend, BrowserUseSettings } from './browser-use.types.js';
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
const MAX_PROFILE_NAME_LENGTH = 80;
export const DEFAULT_BROWSER_USE_SETTINGS: BrowserUseSettings = {
enabled: false,
persistSessions: false,
defaultProfileName: 'default',
browserBackend: IS_PLATFORM ? 'camoufox-vnc' : 'playwright',
};
export const PROFILE_ROOT = process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT
|| path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
export function normalizeBrowserBackend(value: unknown): BrowserUseBackend {
return value === 'playwright' || value === 'camoufox-vnc'
? value
: DEFAULT_BROWSER_USE_SETTINGS.browserBackend;
}
function trimEdgeDashes(value: string): string {
let start = 0;
let end = value.length;
while (start < end && value[start] === '-') {
start += 1;
}
while (end > start && value[end - 1] === '-') {
end -= 1;
}
return value.slice(start, end);
}
export function normalizeProfileName(profileName?: string | null): string | null {
const sanitized = trimEdgeDashes(String(profileName || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-'));
const normalized = sanitized
.slice(0, MAX_PROFILE_NAME_LENGTH)
.replace(/^-+|-+$/g, '');
if (!normalized) {
return null;
}
return /[a-z0-9]/.test(normalized) ? normalized : null;
}
export function normalizeDefaultProfileName(profileName?: string | null): string {
return normalizeProfileName(profileName) || DEFAULT_BROWSER_USE_SETTINGS.defaultProfileName;
}
export function resolveSessionProfileName(settings: BrowserUseSettings, profileName?: string | null): string | null {
const requestedProfileName = normalizeProfileName(profileName);
if (String(profileName || '').trim() && !requestedProfileName) {
throw new Error('Browser profile name must include at least one letter or number.');
}
if (requestedProfileName) {
validateRequestedProfileName(profileName, requestedProfileName);
return requestedProfileName;
}
return settings.persistSessions ? normalizeDefaultProfileName(settings.defaultProfileName) : null;
}
export function getProfilePath(profileName: string): string {
return path.join(PROFILE_ROOT, normalizeDefaultProfileName(profileName));
}
function validateRequestedProfileName(profileName: string | null | undefined, normalizedProfileName: string): void {
const requestedProfileName = String(profileName || '').trim();
const existingProfileName = findExistingProfileName(normalizedProfileName);
if (
existingProfileName
&& (requestedProfileName !== normalizedProfileName || existingProfileName !== normalizedProfileName)
) {
throw new Error(`Browser profile "${requestedProfileName}" resolves to existing profile "${existingProfileName}". Use "${normalizedProfileName}" instead.`);
}
}
function findExistingProfileName(normalizedProfileName: string): string | null {
try {
if (!fs.existsSync(PROFILE_ROOT)) {
return null;
}
const entries = fs.readdirSync(PROFILE_ROOT, { withFileTypes: true });
const match = entries.find((entry) => entry.isDirectory() && normalizeProfileName(entry.name) === normalizedProfileName);
return match?.name || null;
} catch {
return null;
}
}
export function useVisibleCamoufoxBackend(settings: BrowserUseSettings): boolean {
return settings.browserBackend === 'camoufox-vnc';
}
export function readSettings(): BrowserUseSettings {
try {
const raw = appConfigDb.get(BROWSER_USE_SETTINGS_KEY);
if (!raw) {
return DEFAULT_BROWSER_USE_SETTINGS;
}
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
return {
enabled: parsed.enabled === true,
persistSessions: parsed.persistSessions === true,
defaultProfileName: normalizeDefaultProfileName(parsed.defaultProfileName),
browserBackend: normalizeBrowserBackend(parsed.browserBackend),
};
} catch (error: any) {
console.warn('[Browser] Failed to read settings:', error?.message || error);
return DEFAULT_BROWSER_USE_SETTINGS;
}
}
export function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
const normalized = {
enabled: settings.enabled === true,
persistSessions: settings.persistSessions === true,
defaultProfileName: normalizeDefaultProfileName(settings.defaultProfileName),
browserBackend: normalizeBrowserBackend(settings.browserBackend),
};
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
return normalized;
}
export function getOrCreateMcpToken(): string {
const existing = appConfigDb.get(BROWSER_USE_MCP_TOKEN_KEY);
if (existing) {
return existing;
}
const token = randomBytes(32).toString('hex');
appConfigDb.set(BROWSER_USE_MCP_TOKEN_KEY, token);
return token;
}

View File

@@ -0,0 +1,66 @@
import type { spawn } from 'node:child_process';
export type BrowserUseRuntime = 'cloud' | 'local';
export type BrowserUseBackend = 'playwright' | 'camoufox-vnc';
export type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
export type BrowserUseSession = {
id: string;
ownerId: string;
createdBy: 'agent';
runtime: BrowserUseRuntime;
status: BrowserUseSessionStatus;
url: string | null;
title: string | null;
screenshotDataUrl: string | null;
createdAt: string;
updatedAt: string;
lastAction: string | null;
message: string | null;
backend: BrowserUseBackend;
viewerUrl: string | null;
viewerEmbedUrl: string | null;
profileName: string | null;
viewport: {
width: number;
height: number;
} | null;
cursor: {
x: number;
y: number;
actor: 'agent';
} | null;
};
export type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
export type RuntimeHandle = {
browser?: any;
context?: any;
page?: any;
processes?: Array<ReturnType<typeof spawn>>;
viewer?: {
display: string;
vncPort: number;
websockifyPort: number;
noVncRoot: string;
};
};
export type BrowserUseSettings = {
enabled: boolean;
persistSessions: boolean;
defaultProfileName: string;
browserBackend: BrowserUseBackend;
};
export type RuntimeReadiness = {
playwright: any | null;
playwrightInstalled: boolean;
chromiumInstalled: boolean;
chromiumExecutablePath: string | null;
installInProgress: boolean;
installMessage: string | null;
};
export type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;

View File

@@ -0,0 +1,76 @@
import { WebSocket } from 'ws';
import type { RuntimeHandle } from './browser-use.types.js';
type BrowserUseViewer = NonNullable<RuntimeHandle['viewer']>;
export const VIEWER_COOKIE_NAME = 'browser_use_viewer_token';
const DEFAULT_VIEWER_TOKEN_TTL_MS = 30 * 60 * 1000;
const parsedViewerTokenTtlMs = Number.parseInt(
process.env.CLOUDCLI_BROWSER_USE_VIEWER_TOKEN_TTL_MS || String(DEFAULT_VIEWER_TOKEN_TTL_MS),
10,
);
export const VIEWER_TOKEN_TTL_MS =
Number.isFinite(parsedViewerTokenTtlMs) && parsedViewerTokenTtlMs > 0
? parsedViewerTokenTtlMs
: DEFAULT_VIEWER_TOKEN_TTL_MS;
export function getViewerUrl(sessionId: string, viewerToken?: string): string {
const basePath = `/api/browser-use/sessions/${encodeURIComponent(sessionId)}/viewer`;
const websockifyPath = viewerToken
? `${basePath}/websockify?viewerToken=${encodeURIComponent(viewerToken)}`
: `${basePath}/websockify`;
const params = new URLSearchParams({
autoconnect: '1',
resize: 'scale',
reconnect: '1',
path: websockifyPath,
});
if (viewerToken) {
params.set('viewerToken', viewerToken);
}
return `${basePath}/vnc.html?${params.toString()}`;
}
export function handleViewerWebSocket(
clientWs: WebSocket,
pathname: string,
getSessionViewer: (sessionId: string) => BrowserUseViewer | null | undefined,
) {
const match = /^\/api\/browser-use\/sessions\/([^/]+)\/viewer\/websockify\/?$/.exec(pathname);
const sessionId = match ? decodeURIComponent(match[1]) : '';
const viewer = sessionId ? getSessionViewer(sessionId) : null;
if (!viewer) {
clientWs.close(4404, 'Browser viewer not found');
return;
}
const upstream = new WebSocket(`ws://127.0.0.1:${viewer.websockifyPort}`);
upstream.on('open', () => {
clientWs.on('message', (data) => {
if (upstream.readyState === WebSocket.OPEN) {
upstream.send(data);
}
});
upstream.on('message', (data) => {
if (clientWs.readyState === WebSocket.OPEN) {
clientWs.send(data);
}
});
});
upstream.on('close', (code, reason) => {
if (clientWs.readyState === WebSocket.OPEN) {
clientWs.close(code, reason);
}
});
upstream.on('error', () => {
if (clientWs.readyState === WebSocket.OPEN) {
clientWs.close(4502, 'Browser viewer upstream error');
}
});
clientWs.on('close', () => {
if (upstream.readyState === WebSocket.OPEN) {
upstream.close();
}
});
}

View File

@@ -0,0 +1,2 @@
export { browserUseService } from './browser-use.service.js';
export { VIEWER_COOKIE_NAME } from './browser-use.viewer.js';

View File

@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
const originalProfileRoot = process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT;
const testProfileRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'browser-use-profiles-'));
process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT = testProfileRoot;
const {
getProfilePath,
normalizeDefaultProfileName,
normalizeProfileName,
PROFILE_ROOT,
resolveSessionProfileName,
} = await import('@/modules/browser-use/browser-use.settings.js');
test.after(() => {
if (originalProfileRoot === undefined) {
delete process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT;
} else {
process.env.CLOUDCLI_BROWSER_USE_PROFILE_ROOT = originalProfileRoot;
}
fs.rmSync(testProfileRoot, { recursive: true, force: true });
});
test('browser profile names are canonicalized before storage and path resolution', () => {
assert.equal(normalizeProfileName(' Work Profile!! '), 'work-profile');
assert.equal(normalizeProfileName(`${'-'.repeat(100)}Work Profile`), 'work-profile');
assert.equal(normalizeDefaultProfileName(' Work Profile!! '), 'work-profile');
assert.equal(
getProfilePath(' Work Profile!! '),
`${PROFILE_ROOT}/work-profile`,
);
assert.equal(
resolveSessionProfileName({
enabled: true,
persistSessions: true,
defaultProfileName: ' Work Profile!! ',
browserBackend: 'playwright',
}),
'work-profile',
);
});
test('browser profile aliases are rejected when the normalized profile already exists', () => {
const profileName = `alias-test-${Date.now()}`;
fs.mkdirSync(getProfilePath(profileName), { recursive: true });
try {
assert.throws(
() => resolveSessionProfileName({
enabled: true,
persistSessions: false,
defaultProfileName: 'default',
browserBackend: 'playwright',
}, profileName.toUpperCase()),
/resolves to existing profile/,
);
assert.equal(
resolveSessionProfileName({
enabled: true,
persistSessions: false,
defaultProfileName: 'default',
browserBackend: 'playwright',
}, profileName),
profileName,
);
} finally {
fs.rmSync(getProfilePath(profileName), { recursive: true, force: true });
}
});

View File

@@ -1,8 +1,9 @@
import type { Server as HttpServer } from 'node:http'; import type { Server as HttpServer } from 'node:http';
import { WebSocketServer, type VerifyClientCallbackSync } from 'ws'; import { WebSocket, WebSocketServer, type VerifyClientCallbackSync } from 'ws';
import { handleChatConnection } from '@/modules/websocket/services/chat-websocket.service.js'; import { handleChatConnection } from '@/modules/websocket/services/chat-websocket.service.js';
import { VIEWER_COOKIE_NAME } from '@/modules/browser-use/index.js';
import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js'; import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js';
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js'; import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js'; import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
@@ -14,8 +15,21 @@ type WebSocketServerDependencies = {
chat: Parameters<typeof handleChatConnection>[2]; chat: Parameters<typeof handleChatConnection>[2];
shell: Parameters<typeof handleShellConnection>[1]; shell: Parameters<typeof handleShellConnection>[1];
getPluginPort: Parameters<typeof handlePluginWsProxy>[2]; getPluginPort: Parameters<typeof handlePluginWsProxy>[2];
browserUseViewer?: (ws: WebSocket, pathname: string) => void;
authenticateBrowserUseViewer?: (pathname: string, token: string | null) => boolean;
}; };
function readCookieValue(header: unknown, name: string): string | null {
if (!header) return null;
const prefix = `${name}=`;
const cookie = String(header).split(';').map((part) => part.trim()).find((part) => part.startsWith(prefix));
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null;
}
function getBrowserUseViewerToken(url: URL, headers: Record<string, unknown>): string | null {
return url.searchParams.get('viewerToken') || readCookieValue(headers.cookie, VIEWER_COOKIE_NAME);
}
/** /**
* Creates and wires the server-wide websocket gateway used for chat, shell, and * Creates and wires the server-wide websocket gateway used for chat, shell, and
* plugin proxy routes. * plugin proxy routes.
@@ -28,7 +42,17 @@ export function createWebSocketServer(
server, server,
verifyClient: (( verifyClient: ((
info: Parameters<VerifyClientCallbackSync<AuthenticatedWebSocketRequest>>[0] info: Parameters<VerifyClientCallbackSync<AuthenticatedWebSocketRequest>>[0]
) => verifyWebSocketClient(info, dependencies.verifyClient)), ) => {
const requestUrl = new URL(info.req.url ?? '/', 'http://localhost');
if (
requestUrl.pathname.startsWith('/api/browser-use/sessions/')
&& requestUrl.pathname.endsWith('/viewer/websockify')
) {
const token = getBrowserUseViewerToken(requestUrl, info.req.headers as Record<string, unknown>);
return Boolean(dependencies.authenticateBrowserUseViewer?.(requestUrl.pathname, token));
}
return verifyWebSocketClient(info, dependencies.verifyClient);
}),
}); });
wss.on('connection', (ws, request) => { wss.on('connection', (ws, request) => {
@@ -74,6 +98,11 @@ export function createWebSocketServer(
return; return;
} }
if (pathname.startsWith('/api/browser-use/sessions/') && pathname.endsWith('/viewer/websockify')) {
dependencies.browserUseViewer?.(ws, pathname);
return;
}
console.log('[WARN] Unknown WebSocket path:', pathname); console.log('[WARN] Unknown WebSocket path:', pathname);
ws.close(); ws.close();
}); });

View File

@@ -1,3 +1,5 @@
import { AlertCircle } from 'lucide-react';
type AuthErrorAlertProps = { type AuthErrorAlertProps = {
errorMessage: string; errorMessage: string;
}; };
@@ -8,8 +10,12 @@ export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) {
} }
return ( return (
<div className="rounded-md border border-red-300 bg-red-100 p-3 dark:border-red-800 dark:bg-red-900/20"> <div
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p> role="alert"
className="flex items-start gap-2.5 rounded-xl border border-destructive/30 bg-destructive/10 p-3 text-destructive"
>
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<p className="text-sm leading-relaxed">{errorMessage}</p>
</div> </div>
); );
} }

View File

@@ -1,3 +1,7 @@
import { useState } from 'react';
import type { ComponentType } from 'react';
import { Eye, EyeOff } from 'lucide-react';
type AuthInputFieldProps = { type AuthInputFieldProps = {
id: string; id: string;
label: string; label: string;
@@ -8,13 +12,14 @@ type AuthInputFieldProps = {
type?: 'text' | 'password' | 'email'; type?: 'text' | 'password' | 'email';
name?: string; name?: string;
autoComplete?: string; autoComplete?: string;
icon?: ComponentType<{ className?: string }>;
}; };
/** /**
* A labelled input field for authentication forms. * A labelled input field for authentication forms.
* Renders a `<label>` / `<input>` pair and forwards browser autofill hints * Renders a `<label>` / `<input>` pair and forwards browser autofill hints
* (`name`, `autoComplete`) so that password managers can identify and fill * (`name`, `autoComplete`) so that password managers can identify and fill
* the field correctly. * the field correctly. Password fields gain a show/hide visibility toggle.
*/ */
export default function AuthInputField({ export default function AuthInputField({
id, id,
@@ -26,24 +31,48 @@ export default function AuthInputField({
type = 'text', type = 'text',
name, name,
autoComplete, autoComplete,
icon: Icon,
}: AuthInputFieldProps) { }: AuthInputFieldProps) {
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const isPasswordField = type === 'password';
const resolvedType = isPasswordField && isPasswordVisible ? 'text' : type;
return ( return (
<div> <div>
<label htmlFor={id} className="mb-1 block text-sm font-medium text-foreground"> <label htmlFor={id} className="mb-1.5 block text-sm font-medium text-foreground">
{label} {label}
</label> </label>
<input <div className="group relative">
id={id} {Icon && (
type={type} <Icon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground transition-colors group-focus-within:text-primary" />
name={name ?? id} )}
autoComplete={autoComplete} <input
value={value} id={id}
onChange={(event) => onChange(event.target.value)} type={resolvedType}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500" name={name ?? id}
placeholder={placeholder} autoComplete={autoComplete}
required value={value}
disabled={isDisabled} onChange={(event) => onChange(event.target.value)}
/> className={`w-full rounded-xl border border-border bg-background/60 py-2.5 text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/60 hover:border-foreground/20 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60 ${
Icon ? 'pl-10' : 'pl-3.5'
} ${isPasswordField ? 'pr-11' : 'pr-3.5'}`}
placeholder={placeholder}
required
disabled={isDisabled}
/>
{isPasswordField && (
<button
type="button"
onClick={() => setIsPasswordVisible((previous) => !previous)}
disabled={isDisabled}
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
className="absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60"
>
{isPasswordVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
)}
</div>
</div> </div>
); );
} }

View File

@@ -1,30 +1,37 @@
import { MessageSquare } from 'lucide-react'; import { CLOUDCLI_WORDMARK_FONT_FAMILY } from '../../../constants/branding';
const loadingDotAnimationDelays = ['0s', '0.1s', '0.2s']; const loadingDotAnimationDelays = ['0s', '0.15s', '0.3s'];
export default function AuthLoadingScreen() { export default function AuthLoadingScreen() {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background p-4"> <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
<div className="text-center"> <div aria-hidden className="pointer-events-none absolute inset-0">
<div className="mb-4 flex justify-center"> <div className="absolute -top-40 left-1/2 h-[36rem] w-[36rem] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm"> </div>
<MessageSquare className="h-8 w-8 text-primary-foreground" />
<div className="relative text-center" role="status" aria-live="polite">
<div className="mb-5 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-primary/80 shadow-lg shadow-primary/25 ring-1 ring-inset ring-white/20">
<img src="/logo.svg" alt="CloudCLI" className="h-9 w-9" />
</div> </div>
</div> </div>
<h1 className="mb-2 text-2xl font-bold text-foreground">CloudCLI</h1> <h1
className="mb-4 text-2xl font-bold tracking-tight text-foreground"
<div className="flex items-center justify-center space-x-2"> style={{ fontFamily: CLOUDCLI_WORDMARK_FONT_FAMILY }}
>
CloudCLI
</h1>
<p className="sr-only">Loading authentication state</p>
<div aria-hidden className="flex items-center justify-center gap-2">
{loadingDotAnimationDelays.map((delay) => ( {loadingDotAnimationDelays.map((delay) => (
<div <div
key={delay} key={delay}
className="h-2 w-2 animate-bounce rounded-full bg-blue-500" className="h-2 w-2 animate-bounce rounded-full bg-primary"
style={{ animationDelay: delay }} style={{ animationDelay: delay }}
/> />
))} ))}
</div> </div>
<p className="mt-2 text-muted-foreground">Loading...</p>
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,4 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { MessageSquare } from 'lucide-react';
import { IS_PLATFORM } from '../../../constants/config'; import { IS_PLATFORM } from '../../../constants/config';
type AuthScreenLayoutProps = { type AuthScreenLayoutProps = {
@@ -18,29 +17,38 @@ export default function AuthScreenLayout({
logo, logo,
}: AuthScreenLayoutProps) { }: AuthScreenLayoutProps) {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background p-4"> <div className="relative h-screen overflow-y-auto bg-background">
<div className="w-full max-w-md"> {/* Ambient, on-brand backdrop that gives the screen depth without
<div className="space-y-6 rounded-lg border border-border bg-card p-8 shadow-lg"> competing with the card content. Fixed so it stays put while the
form scrolls on short viewports. */}
<div aria-hidden className="pointer-events-none fixed inset-0">
<div className="absolute -top-40 left-1/2 h-[36rem] w-[36rem] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
<div className="absolute -bottom-32 -left-24 h-[26rem] w-[26rem] rounded-full bg-primary/5 blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(hsl(var(--foreground)/0.04)_1px,transparent_1px)] [background-size:22px_22px] opacity-60" />
</div>
<div className="relative mx-auto flex min-h-full w-full max-w-md items-center justify-center p-4 py-8">
<div className="w-full rounded-2xl border border-border/70 bg-card/90 p-8 shadow-[0_24px_60px_-20px_hsl(var(--foreground)/0.18)] ring-1 ring-foreground/5 backdrop-blur-xl sm:p-10">
<div className="text-center"> <div className="text-center">
<div className="mb-4 flex justify-center"> <div className="mb-5 flex justify-center">
{logo ?? ( {logo ?? (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm"> <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-primary/80 shadow-lg shadow-primary/25 ring-1 ring-inset ring-white/20">
<MessageSquare className="h-8 w-8 text-primary-foreground" /> <img src="/logo.svg" alt="CloudCLI" className="h-9 w-9" />
</div> </div>
)} )}
</div> </div>
<h1 className="text-2xl font-bold text-foreground">{title}</h1> <h1 className="font-serif text-3xl font-bold tracking-tight text-foreground">{title}</h1>
<p className="mt-2 text-muted-foreground">{description}</p> <p className="mx-auto mt-2 max-w-xs text-sm leading-relaxed text-muted-foreground">{description}</p>
</div> </div>
{children} <div className="mt-8">{children}</div>
<div className="text-center"> <div className="mt-6 border-t border-border/60 pt-5 text-center">
<p className="text-sm text-muted-foreground">{footerText}</p> <p className="text-xs leading-relaxed text-muted-foreground">{footerText}</p>
</div> </div>
{!IS_PLATFORM && ( {!IS_PLATFORM && (
<div className="flex items-center justify-center gap-1.5 pt-2"> <div className="mt-4 flex items-center justify-center gap-1.5">
<svg className="h-3.5 w-3.5 text-muted-foreground/50" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg className="h-3.5 w-3.5 text-muted-foreground/50" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" /> <path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
</svg> </svg>

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Loader2, Lock, User } from 'lucide-react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import AuthErrorAlert from './AuthErrorAlert'; import AuthErrorAlert from './AuthErrorAlert';
import AuthInputField from './AuthInputField'; import AuthInputField from './AuthInputField';
@@ -69,6 +70,7 @@ export default function LoginForm() {
placeholder={t('login.placeholders.username')} placeholder={t('login.placeholders.username')}
isDisabled={isSubmitting} isDisabled={isSubmitting}
autoComplete="username" autoComplete="username"
icon={User}
/> />
<AuthInputField <AuthInputField
@@ -80,6 +82,7 @@ export default function LoginForm() {
isDisabled={isSubmitting} isDisabled={isSubmitting}
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
icon={Lock}
/> />
<AuthErrorAlert errorMessage={errorMessage} /> <AuthErrorAlert errorMessage={errorMessage} />
@@ -87,9 +90,16 @@ export default function LoginForm() {
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400" className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 hover:shadow-primary/30 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:ring-offset-2 focus:ring-offset-card active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60"
> >
{isSubmitting ? t('login.loading') : t('login.submit')} {isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('login.loading')}
</>
) : (
t('login.submit')
)}
</button> </button>
</form> </form>
</AuthScreenLayout> </AuthScreenLayout>

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { Loader2, Lock, ShieldCheck, User } from 'lucide-react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import AuthErrorAlert from './AuthErrorAlert'; import AuthErrorAlert from './AuthErrorAlert';
import AuthInputField from './AuthInputField'; import AuthInputField from './AuthInputField';
@@ -85,7 +86,6 @@ export default function SetupForm() {
title="Welcome to CloudCLI" title="Welcome to CloudCLI"
description="Set up your account to get started" description="Set up your account to get started"
footerText="This is a single-user system. Only one account can be created." footerText="This is a single-user system. Only one account can be created."
logo={<img src="/logo.svg" alt="CloudCLI" className="h-16 w-16" />}
> >
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<AuthInputField <AuthInputField
@@ -94,9 +94,10 @@ export default function SetupForm() {
label="Username" label="Username"
value={formState.username} value={formState.username}
onChange={(value) => updateField('username', value)} onChange={(value) => updateField('username', value)}
placeholder="Enter your username" placeholder="Choose a username"
isDisabled={isSubmitting} isDisabled={isSubmitting}
autoComplete="username" autoComplete="username"
icon={User}
/> />
<AuthInputField <AuthInputField
@@ -105,10 +106,11 @@ export default function SetupForm() {
label="Password" label="Password"
value={formState.password} value={formState.password}
onChange={(value) => updateField('password', value)} onChange={(value) => updateField('password', value)}
placeholder="Enter your password" placeholder="Create a password"
isDisabled={isSubmitting} isDisabled={isSubmitting}
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
icon={Lock}
/> />
<AuthInputField <AuthInputField
@@ -117,20 +119,33 @@ export default function SetupForm() {
label="Confirm Password" label="Confirm Password"
value={formState.confirmPassword} value={formState.confirmPassword}
onChange={(value) => updateField('confirmPassword', value)} onChange={(value) => updateField('confirmPassword', value)}
placeholder="Confirm your password" placeholder="Re-enter your password"
isDisabled={isSubmitting} isDisabled={isSubmitting}
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
icon={ShieldCheck}
/> />
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5" />
At least 3 characters for username, 6 for password.
</p>
<AuthErrorAlert errorMessage={errorMessage} /> <AuthErrorAlert errorMessage={errorMessage} />
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400" className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 hover:shadow-primary/30 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:ring-offset-2 focus:ring-offset-card active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60"
> >
{isSubmitting ? 'Setting up...' : 'Create Account'} {isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Setting up...
</>
) : (
'Create Account'
)}
</button> </button>
</form> </form>
</AuthScreenLayout> </AuthScreenLayout>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Bot, Bot,
Clock3, Clock3,
@@ -7,6 +7,7 @@ import {
ExternalLink, ExternalLink,
Loader2, Loader2,
MonitorPlay, MonitorPlay,
MousePointer2,
RefreshCw, RefreshCw,
Settings, Settings,
Square, Square,
@@ -19,9 +20,14 @@ import { Badge, Button } from '../../../shared/view/ui';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import type { SettingsMainTab } from '../../settings/types/types'; import type { SettingsMainTab } from '../../settings/types/types';
const BROWSER_USE_GUIDE_URL = 'https://cloudcli.ai/docs/browser-use';
const BROWSER_USE_CACHE_TTL_MS = 30_000;
type BrowserUseStatus = { type BrowserUseStatus = {
enabled: boolean; enabled: boolean;
available: boolean; available: boolean;
backend: 'playwright' | 'camoufox-vnc';
browserBackend: 'playwright' | 'camoufox-vnc';
playwrightInstalled: boolean; playwrightInstalled: boolean;
chromiumInstalled: boolean; chromiumInstalled: boolean;
installInProgress: boolean; installInProgress: boolean;
@@ -39,6 +45,9 @@ type BrowserUseSession = {
updatedAt: string; updatedAt: string;
lastAction: string | null; lastAction: string | null;
message: string | null; message: string | null;
backend?: 'playwright' | 'camoufox-vnc';
viewerUrl?: string | null;
viewerEmbedUrl?: string | null;
createdBy: 'agent'; createdBy: 'agent';
profileName: string | null; profileName: string | null;
viewport: { viewport: {
@@ -54,17 +63,48 @@ type BrowserUseSession = {
type BrowserUsePanelProps = { type BrowserUsePanelProps = {
isVisible: boolean; isVisible: boolean;
projectId?: string | null;
onShowSettings?: (tab?: SettingsMainTab) => void; onShowSettings?: (tab?: SettingsMainTab) => void;
}; };
type BrowserUsePanelCacheEntry = {
status: BrowserUseStatus | null;
sessions: BrowserUseSession[];
selectedSessionId: string | null;
updatedAt: number;
};
const browserUsePanelCache = new Map<string, BrowserUsePanelCacheEntry>();
async function readJson<T>(response: Response): Promise<T> { async function readJson<T>(response: Response): Promise<T> {
const data = await response.json(); const text = await response.text();
let data: any = {};
if (text) {
try {
data = JSON.parse(text);
} catch {
throw new Error(response.ok ? 'Received an invalid Browser response.' : `Browser request failed (${response.status}).`);
}
}
if (!response.ok || data.success === false) { if (!response.ok || data.success === false) {
throw new Error(data.error || data.details || `Request failed (${response.status})`); throw new Error(data.error || data.details || `Request failed (${response.status})`);
} }
return data as T; return data as T;
} }
async function fetchBrowserPanelData() {
const [statusResponse, sessionsResponse] = await Promise.all([
authenticatedFetch('/api/browser-use/status'),
authenticatedFetch('/api/browser-use/sessions'),
]);
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
const sessionsData = await readJson<{ data: { sessions: BrowserUseSession[] } }>(sessionsResponse);
return {
status: statusData.data,
sessions: [...sessionsData.data.sessions].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)),
};
}
function formatRelativeTime(value: string | null): string { function formatRelativeTime(value: string | null): string {
if (!value) return 'Never'; if (!value) return 'Never';
@@ -119,20 +159,42 @@ function getStatusDot(status: BrowserUseSession['status']): string {
return 'bg-border'; return 'bg-border';
} }
function getEngineLabel(backend?: BrowserUseStatus['backend'] | BrowserUseSession['backend']): string {
return backend === 'camoufox-vnc' ? 'Visible browser' : 'Playwright';
}
const PROMPTS = [ const PROMPTS = [
'Use Browser to inspect the checkout flow and report any broken UI states.', 'Use Browser to inspect the checkout flow and report any broken UI states.',
'Open <url> with Browser, interact with the page, and summarize what changed after each step.', 'Open <url> with Browser, interact with the page, and summarize what changed after each step.',
]; ];
export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUsePanelProps) { function getBrowserUseCacheKey(projectId?: string | null): string {
const [status, setStatus] = useState<BrowserUseStatus | null>(null); return projectId ? `browser-use:project:${projectId}` : 'browser-use:global';
const [sessions, setSessions] = useState<BrowserUseSession[]>([]); }
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
function getFreshCacheEntry(cacheKey: string): BrowserUsePanelCacheEntry | null {
const entry = browserUsePanelCache.get(cacheKey);
if (!entry || Date.now() - entry.updatedAt > BROWSER_USE_CACHE_TTL_MS) {
return null;
}
return entry;
}
export default function BrowserUsePanel({ isVisible, projectId, onShowSettings }: BrowserUsePanelProps) {
const cacheKey = getBrowserUseCacheKey(projectId);
const initialCacheEntry = getFreshCacheEntry(cacheKey);
const [status, setStatus] = useState<BrowserUseStatus | null>(() => initialCacheEntry?.status ?? null);
const [sessions, setSessions] = useState<BrowserUseSession[]>(() => initialCacheEntry?.sessions ?? []);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(() => (
initialCacheEntry?.selectedSessionId || initialCacheEntry?.sessions[0]?.id || null
));
const [hasLoadedOnce, setHasLoadedOnce] = useState(Boolean(initialCacheEntry));
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [isBusy, setIsBusy] = useState(false); const [isBusy, setIsBusy] = useState(false);
const [isInstalling, setIsInstalling] = useState(false); const [isInstalling, setIsInstalling] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const activeLoadIdRef = useRef(0);
const selectedSession = useMemo( const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null, () => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null,
@@ -140,8 +202,12 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
); );
const activeSessions = sessions.filter((session) => session.status === 'ready'); const activeSessions = sessions.filter((session) => session.status === 'ready');
const needsBrowserBinaries = Boolean(status?.enabled && (!status.playwrightInstalled || !status.chromiumInstalled)); const isInitialLoading = isRefreshing && !hasLoadedOnce && sessions.length === 0;
const runtimeLabel = !status?.enabled const isBackgroundRefreshing = isRefreshing && !isInitialLoading;
const needsBrowserBinaries = Boolean(status?.enabled && !status.available);
const runtimeLabel = isInitialLoading
? 'Loading'
: !status?.enabled
? 'Disabled' ? 'Disabled'
: status.available : status.available
? 'Ready' ? 'Ready'
@@ -157,29 +223,72 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
: null; : null;
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const loadId = activeLoadIdRef.current + 1;
activeLoadIdRef.current = loadId;
setIsRefreshing(true); setIsRefreshing(true);
try { try {
const [statusResponse, sessionsResponse] = await Promise.all([ let nextData: Awaited<ReturnType<typeof fetchBrowserPanelData>>;
authenticatedFetch('/api/browser-use/status'), try {
authenticatedFetch('/api/browser-use/sessions'), nextData = await fetchBrowserPanelData();
]); } catch (error) {
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse); if (loadId !== activeLoadIdRef.current) {
const sessionsData = await readJson<{ data: { sessions: BrowserUseSession[] } }>(sessionsResponse); return;
const nextSessions = sessionsData.data.sessions; }
setStatus(statusData.data); await new Promise((resolve) => setTimeout(resolve, 350));
nextData = await fetchBrowserPanelData();
}
if (activeLoadIdRef.current !== loadId) {
return;
}
const nextSessions = nextData.sessions;
setStatus(nextData.status);
setSessions(nextSessions); setSessions(nextSessions);
setSelectedSessionId((current) => ( setHasLoadedOnce(true);
current && nextSessions.some((session) => session.id === current) let nextSelectedSessionId: string | null = null;
setSelectedSessionId((current) => {
nextSelectedSessionId = current && nextSessions.some((session) => session.id === current)
? current ? current
: nextSessions[0]?.id || null : nextSessions[0]?.id || null;
)); return nextSelectedSessionId;
});
browserUsePanelCache.set(cacheKey, {
status: nextData.status,
sessions: nextSessions,
selectedSessionId: nextSelectedSessionId,
updatedAt: Date.now(),
});
setError(null); setError(null);
} catch (err) { } catch (err) {
if (activeLoadIdRef.current !== loadId) {
return;
}
setHasLoadedOnce(true);
setError(err instanceof Error ? err.message : 'Failed to load Browser'); setError(err instanceof Error ? err.message : 'Failed to load Browser');
} finally { } finally {
setIsRefreshing(false); if (activeLoadIdRef.current === loadId) {
setIsRefreshing(false);
}
} }
}, []); }, [cacheKey]);
useEffect(() => {
const cachedEntry = browserUsePanelCache.get(cacheKey);
if (!cachedEntry) return;
browserUsePanelCache.set(cacheKey, {
...cachedEntry,
selectedSessionId,
});
}, [cacheKey, selectedSessionId]);
useEffect(() => {
const cachedEntry = getFreshCacheEntry(cacheKey);
setStatus(cachedEntry?.status ?? null);
setSessions(cachedEntry?.sessions ?? []);
setSelectedSessionId(cachedEntry?.selectedSessionId || cachedEntry?.sessions[0]?.id || null);
setHasLoadedOnce(Boolean(cachedEntry));
setError(null);
activeLoadIdRef.current += 1;
}, [cacheKey]);
useEffect(() => { useEffect(() => {
if (!isVisible) return; if (!isVisible) return;
@@ -253,6 +362,10 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
<span>{formatRelativeTime(session.updatedAt)}</span> <span>{formatRelativeTime(session.updatedAt)}</span>
<span className="truncate">- {formatAction(session.lastAction)}</span> <span className="truncate">- {formatAction(session.lastAction)}</span>
</div> </div>
<div className="mt-2 flex flex-wrap gap-1.5 pl-3.5 text-[10px] text-muted-foreground">
<span className="rounded border border-border/70 bg-background/70 px-1.5 py-0.5">{getEngineLabel(session.backend)}</span>
<span className="rounded border border-border/70 bg-background/70 px-1.5 py-0.5">{session.profileName || 'Temporary'}</span>
</div>
</button> </button>
); );
}; };
@@ -270,9 +383,18 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
</div> </div>
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground"> <p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
{status?.enabled {status?.enabled
? 'Agent browser sessions appear here while an AI task is using Browser.' ? 'When an agent opens a browser, you can watch the latest screenshot, take control in a new tab, or end the running session.'
: 'Enable Browser in settings to let agents open monitored browser sessions.'} : 'Enable Browser to let agents open websites, test flows, capture screenshots, and debug UI from a real page.'}
</p> </p>
<a
href={BROWSER_USE_GUIDE_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
Read the Browser guide
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div> </div>
</div> </div>
@@ -312,10 +434,19 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
</div> </div>
); );
const renderLoadingState = () => (
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<div className="flex items-center gap-3 rounded-md border border-border bg-card/40 px-4 py-3 text-sm text-muted-foreground shadow-sm">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
Loading browser sessions...
</div>
</div>
);
const renderBrowserSurface = (fullscreen = false) => ( const renderBrowserSurface = (fullscreen = false) => (
<div className={cn('flex flex-1 items-center justify-center bg-neutral-950', fullscreen ? 'min-h-[80vh]' : 'min-h-[420px]')}> <div className={cn('flex flex-1 items-center justify-center bg-neutral-950', fullscreen ? 'min-h-[80vh]' : 'min-h-[420px]')}>
{selectedSession?.screenshotDataUrl ? ( {selectedSession?.screenshotDataUrl ? (
<div className="relative inline-block max-h-full"> <div className="group relative inline-block max-h-full">
<img <img
src={selectedSession.screenshotDataUrl} src={selectedSession.screenshotDataUrl}
alt="Browser session screenshot" alt="Browser session screenshot"
@@ -329,6 +460,18 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" /> <div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white" />
</div> </div>
)} )}
{selectedSession?.viewerEmbedUrl && selectedSession.status === 'ready' && (
<button
type="button"
onClick={() => window.open(selectedSession.viewerUrl || selectedSession.viewerEmbedUrl || '', '_blank', 'noopener,noreferrer')}
className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition focus-visible:bg-black/30 focus-visible:opacity-100 focus-visible:outline-none group-hover:bg-black/30 group-hover:opacity-100"
>
<span className="inline-flex items-center gap-2 rounded-md border border-white/20 bg-black/80 px-3 py-2 text-sm font-medium text-white shadow-lg">
<MousePointer2 className="h-4 w-4" />
Take control
</span>
</button>
)}
</div> </div>
) : ( ) : (
<div className="px-6 text-center"> <div className="px-6 text-center">
@@ -350,10 +493,29 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
<Badge variant="outline" className={cn('text-[10px]', getRuntimeTone(status, isInstalling))}> <Badge variant="outline" className={cn('text-[10px]', getRuntimeTone(status, isInstalling))}>
{runtimeLabel} {runtimeLabel}
</Badge> </Badge>
<Badge variant="outline" className="border-border bg-background text-[10px] text-muted-foreground">
{getEngineLabel(status?.backend)}
</Badge>
</div> </div>
<p className="mt-0.5 text-xs text-muted-foreground">Monitor browser sessions opened by AI agents.</p> <p className="mt-0.5 text-xs text-muted-foreground">Watch and manage browser sessions agents use to test real websites.</p>
{isBackgroundRefreshing && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<RefreshCw className="h-3 w-3 animate-spin" />
Refreshing sessions...
</div>
)}
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => window.open(BROWSER_USE_GUIDE_URL, '_blank', 'noopener,noreferrer')}
title="Open Browser guide"
aria-label="Open Browser guide"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
{onShowSettings && ( {onShowSettings && (
<Button <Button
variant="ghost" variant="ghost"
@@ -425,7 +587,7 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
</div> </div>
{sessions.length === 0 ? ( {sessions.length === 0 ? (
renderEmptyState() isInitialLoading ? renderLoadingState() : renderEmptyState()
) : ( ) : (
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4"> <div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
<div className="mx-auto flex min-h-[500px] max-w-7xl flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm"> <div className="mx-auto flex min-h-[500px] max-w-7xl flex-col overflow-hidden rounded-md border border-border bg-background shadow-sm">
@@ -441,14 +603,32 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
<ExternalLink className="h-3.5 w-3.5 shrink-0" /> <ExternalLink className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{selectedSession?.url || 'No page loaded'}</span> <span className="truncate">{selectedSession?.url || 'No page loaded'}</span>
</div> </div>
<div className="mt-1 flex flex-wrap gap-1.5 text-[10px] text-muted-foreground">
<span className="rounded border border-border/70 bg-muted/30 px-1.5 py-0.5">{getEngineLabel(selectedSession?.backend || status?.backend)}</span>
<span className="rounded border border-border/70 bg-muted/30 px-1.5 py-0.5">Profile: {selectedSession?.profileName || 'Temporary'}</span>
<span className="rounded border border-border/70 bg-muted/30 px-1.5 py-0.5">Updated {formatRelativeTime(selectedSession?.updatedAt || null)}</span>
</div>
</div> </div>
<div className="hidden text-xs text-muted-foreground md:block"> <div className="hidden text-xs text-muted-foreground md:block">
{formatAction(selectedSession?.lastAction || null)} {formatAction(selectedSession?.lastAction || null)}
</div> </div>
{selectedSession?.viewerUrl && selectedSession.status === 'ready' && (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => window.open(selectedSession.viewerUrl || '', '_blank', 'noopener,noreferrer')}
title="Open live browser control in a new tab"
aria-label="Open live browser control in a new tab"
>
<MousePointer2 className="h-4 w-4" />
Take control
</Button>
)}
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl} title="Full screen" aria-label="Full screen"> <Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setIsFullscreen(true)} disabled={!selectedSession?.screenshotDataUrl} title="Full screen" aria-label="Full screen">
<Expand className="h-4 w-4" /> <Expand className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'} title="Stop session" aria-label="Stop session"> <Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'} title="End session" aria-label="End session">
<Square className="h-4 w-4" /> <Square className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={deleteSession} disabled={isBusy || !selectedSession} title="Delete session" aria-label="Delete session"> <Button variant="ghost" size="sm" className="h-8 w-8 p-0 lg:hidden" onClick={deleteSession} disabled={isBusy || !selectedSession} title="Delete session" aria-label="Delete session">
@@ -475,6 +655,11 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
<div className="min-h-0 flex-1 overflow-y-auto p-3"> <div className="min-h-0 flex-1 overflow-y-auto p-3">
{sessions.length > 0 ? ( {sessions.length > 0 ? (
<div className="space-y-2">{sessions.map(renderSessionItem)}</div> <div className="space-y-2">{sessions.map(renderSessionItem)}</div>
) : isInitialLoading ? (
<div className="flex items-center justify-center gap-2 rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading sessions...
</div>
) : ( ) : (
<div className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground"> <div className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
No agent browser sessions. No agent browser sessions.
@@ -505,7 +690,7 @@ export default function BrowserUsePanel({ isVisible, onShowSettings }: BrowserUs
<div className="mt-3 grid grid-cols-2 gap-2"> <div className="mt-3 grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}> <Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
<Square className="h-4 w-4" /> <Square className="h-4 w-4" />
Stop End
</Button> </Button>
<Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}> <Button variant="outline" size="sm" onClick={deleteSession} disabled={isBusy || !selectedSession}>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />

View File

@@ -18,7 +18,6 @@ interface UseChatSessionStateArgs {
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
ws: WebSocket | null; ws: WebSocket | null;
sendMessage: (message: unknown) => void; sendMessage: (message: unknown) => void;
autoScrollToBottom?: boolean;
externalMessageUpdate?: number; externalMessageUpdate?: number;
newSessionTrigger?: number; newSessionTrigger?: number;
processingSessions?: SessionActivityMap; processingSessions?: SessionActivityMap;
@@ -96,7 +95,6 @@ export function useChatSessionState({
selectedSession, selectedSession,
ws, ws,
sendMessage, sendMessage,
autoScrollToBottom,
externalMessageUpdate, externalMessageUpdate,
newSessionTrigger, newSessionTrigger,
processingSessions, processingSessions,
@@ -121,6 +119,7 @@ export function useChatSessionState({
const [viewHiddenCount, setViewHiddenCount] = useState(0); const [viewHiddenCount, setViewHiddenCount] = useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const wasNearTopRef = useRef(false);
const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null); const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);
const searchScrollActiveRef = useRef(false); const searchScrollActiveRef = useRef(false);
const isLoadingSessionRef = useRef(false); const isLoadingSessionRef = useRef(false);
@@ -185,6 +184,7 @@ export function useChatSessionState({
setShowLoadAllOverlay(false); setShowLoadAllOverlay(false);
setViewHiddenCount(0); setViewHiddenCount(0);
setSearchTarget(null); setSearchTarget(null);
wasNearTopRef.current = false;
searchScrollActiveRef.current = false; searchScrollActiveRef.current = false;
topLoadLockRef.current = false; topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null; pendingScrollRestoreRef.current = null;
@@ -336,12 +336,34 @@ export function useChatSessionState({
const slot = await sessionStore.fetchMore(selectedSession.id, { const slot = await sessionStore.fetchMore(selectedSession.id, {
limit: MESSAGES_PER_PAGE, limit: MESSAGES_PER_PAGE,
}); });
if (!slot || slot.serverMessages.length === 0) return false; if (!slot) return false;
if (slot.serverMessages.length === 0) {
if (!slot.hasMore) {
setHasMoreMessages(false);
allMessagesLoadedRef.current = true;
setAllMessagesLoaded(true);
if (loadAllOverlayTimerRef.current) {
clearTimeout(loadAllOverlayTimerRef.current);
loadAllOverlayTimerRef.current = null;
}
setShowLoadAllOverlay(false);
}
return false;
}
pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop }; pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop };
setHasMoreMessages(slot.hasMore); setHasMoreMessages(slot.hasMore);
setTotalMessages(slot.total); setTotalMessages(slot.total);
setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE); setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE);
if (!slot.hasMore) {
allMessagesLoadedRef.current = true;
setAllMessagesLoaded(true);
if (loadAllOverlayTimerRef.current) {
clearTimeout(loadAllOverlayTimerRef.current);
loadAllOverlayTimerRef.current = null;
}
setShowLoadAllOverlay(false);
}
return true; return true;
} finally { } finally {
isLoadingMoreRef.current = false; isLoadingMoreRef.current = false;
@@ -357,8 +379,25 @@ export function useChatSessionState({
const nearBottom = isNearBottom(); const nearBottom = isNearBottom();
setIsUserScrolledUp(!nearBottom); setIsUserScrolledUp(!nearBottom);
const scrolledNearTop = container.scrollTop < 100;
// "Load all" prompt: appear (with fade-in) when the user reaches the top
if (scrolledNearTop && hasMoreMessages && !allMessagesLoadedRef.current) {
if (!wasNearTopRef.current) {
wasNearTopRef.current = true;
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
setShowLoadAllOverlay(true);
loadAllOverlayTimerRef.current = setTimeout(() => {
setShowLoadAllOverlay(false);
loadAllOverlayTimerRef.current = null;
}, 2500);
}
} else if (!scrolledNearTop) {
wasNearTopRef.current = false;
}
if (!allMessagesLoadedRef.current) { if (!allMessagesLoadedRef.current) {
const scrolledNearTop = container.scrollTop < 100;
if (!scrolledNearTop) { topLoadLockRef.current = false; return; } if (!scrolledNearTop) { topLoadLockRef.current = false; return; }
if (topLoadLockRef.current) { if (topLoadLockRef.current) {
if (container.scrollTop > 20) topLoadLockRef.current = false; if (container.scrollTop > 20) topLoadLockRef.current = false;
@@ -367,7 +406,7 @@ export function useChatSessionState({
const didLoad = await loadOlderMessages(container); const didLoad = await loadOlderMessages(container);
if (didLoad) topLoadLockRef.current = true; if (didLoad) topLoadLockRef.current = true;
} }
}, [isNearBottom, loadOlderMessages]); }, [hasMoreMessages, isNearBottom, loadOlderMessages]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return; if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
@@ -386,6 +425,7 @@ export function useChatSessionState({
} }
topLoadLockRef.current = false; topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null; pendingScrollRestoreRef.current = null;
wasNearTopRef.current = false;
setIsUserScrolledUp(false); setIsUserScrolledUp(false);
}, [selectedProject?.projectId, selectedSession?.id]); }, [selectedProject?.projectId, selectedSession?.id]);
@@ -492,6 +532,7 @@ export function useChatSessionState({
setLoadAllJustFinished(false); setLoadAllJustFinished(false);
setShowLoadAllOverlay(false); setShowLoadAllOverlay(false);
setViewHiddenCount(0); setViewHiddenCount(0);
wasNearTopRef.current = false;
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
@@ -546,7 +587,7 @@ export function useChatSessionState({
if (!isProcessing) { if (!isProcessing) {
await sessionStore.refreshFromServer(selectedSession.id); await sessionStore.refreshFromServer(selectedSession.id);
if (Boolean(autoScrollToBottom) && isNearBottom()) { if (isNearBottom()) {
setTimeout(() => scrollToBottom(), 200); setTimeout(() => scrollToBottom(), 200);
} }
} }
@@ -557,7 +598,6 @@ export function useChatSessionState({
reloadExternalMessages(); reloadExternalMessages();
}, [ }, [
autoScrollToBottom,
externalMessageUpdate, externalMessageUpdate,
isNearBottom, isNearBottom,
scrollToBottom, scrollToBottom,
@@ -689,10 +729,9 @@ export function useChatSessionState({
}, [chatMessages, visibleMessageCount]); }, [chatMessages, visibleMessageCount]);
useEffect(() => { useEffect(() => {
if (!autoScrollToBottom && scrollContainerRef.current) { const container = scrollContainerRef.current;
const container = scrollContainerRef.current; if (!container) return;
scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop }; scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
}
}); });
useEffect(() => { useEffect(() => {
@@ -700,8 +739,8 @@ export function useChatSessionState({
if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return; if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return;
if (searchScrollActiveRef.current) return; if (searchScrollActiveRef.current) return;
if (autoScrollToBottom) { if (!isUserScrolledUp) {
if (!isUserScrolledUp) setTimeout(() => scrollToBottom(), 50); setTimeout(() => scrollToBottom(), 50);
return; return;
} }
@@ -711,7 +750,7 @@ export function useChatSessionState({
const newHeight = container.scrollHeight; const newHeight = container.scrollHeight;
const heightDiff = newHeight - prevHeight; const heightDiff = newHeight - prevHeight;
if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff; if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff;
}, [autoScrollToBottom, chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]); }, [chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
useEffect(() => { useEffect(() => {
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
@@ -720,23 +759,8 @@ export function useChatSessionState({
return () => container.removeEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll);
}, [handleScroll]); }, [handleScroll]);
// "Load all" overlay // "Load all" overlay visibility is driven by scroll-to-top in handleScroll;
const prevLoadingRef = useRef(false); // timers are cleared on session change via the reset effect above.
useEffect(() => {
const wasLoading = prevLoadingRef.current;
prevLoadingRef.current = isLoadingMoreMessages;
if (wasLoading && !isLoadingMoreMessages && hasMoreMessages) {
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
setShowLoadAllOverlay(true);
loadAllOverlayTimerRef.current = setTimeout(() => setShowLoadAllOverlay(false), 2000);
}
if (!hasMoreMessages && !isLoadingMoreMessages) {
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
setShowLoadAllOverlay(false);
}
return () => { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); };
}, [isLoadingMoreMessages, hasMoreMessages]);
const loadAllMessages = useCallback(async () => { const loadAllMessages = useCallback(async () => {
if (!selectedSession || !selectedProject) return; if (!selectedSession || !selectedProject) return;
@@ -746,6 +770,10 @@ export function useChatSessionState({
isLoadingMoreRef.current = true; isLoadingMoreRef.current = true;
setIsLoadingAllMessages(true); setIsLoadingAllMessages(true);
setShowLoadAllOverlay(true); setShowLoadAllOverlay(true);
if (loadAllOverlayTimerRef.current) {
clearTimeout(loadAllOverlayTimerRef.current);
loadAllOverlayTimerRef.current = null;
}
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
const previousScrollHeight = container ? container.scrollHeight : 0; const previousScrollHeight = container ? container.scrollHeight : 0;
@@ -772,7 +800,11 @@ export function useChatSessionState({
setLoadAllJustFinished(true); setLoadAllJustFinished(true);
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000); loadAllFinishedTimerRef.current = setTimeout(() => {
setLoadAllJustFinished(false);
setShowLoadAllOverlay(false);
loadAllFinishedTimerRef.current = null;
}, 2500);
} else { } else {
allMessagesLoadedRef.current = false; allMessagesLoadedRef.current = false;
setShowLoadAllOverlay(false); setShowLoadAllOverlay(false);

View File

@@ -24,7 +24,6 @@ interface ToolRendererProps {
onFileOpen?: (filePath: string, diffInfo?: any) => void; onFileOpen?: (filePath: string, diffInfo?: any) => void;
createDiff?: (oldStr: string, newStr: string) => DiffLine[]; createDiff?: (oldStr: string, newStr: string) => DiffLine[];
selectedProject?: Project | null; selectedProject?: Project | null;
autoExpandTools?: boolean;
showRawParameters?: boolean; showRawParameters?: boolean;
rawToolInput?: string; rawToolInput?: string;
isSubagentContainer?: boolean; isSubagentContainer?: boolean;
@@ -80,7 +79,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
onFileOpen, onFileOpen,
createDiff, createDiff,
selectedProject, selectedProject,
autoExpandTools = false,
showRawParameters = false, showRawParameters = false,
rawToolInput, rawToolInput,
isSubagentContainer, isSubagentContainer,
@@ -151,8 +149,8 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
output={output} output={output}
isError={Boolean(toolResult?.isError)} isError={Boolean(toolResult?.isError)}
status={toolStatus !== 'completed' ? toolStatus : undefined} status={toolStatus !== 'completed' ? toolStatus : undefined}
// Commands stay collapsed by default (even consecutive ones); only // Commands stay collapsed by default; only failures auto-expand so they
// failures auto-expand so they remain visible. // remain visible.
defaultOpen={false} defaultOpen={false}
/> />
); );
@@ -199,7 +197,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
<PlanDisplay <PlanDisplay
title={title} title={title}
content={contentProps.content || ''} content={contentProps.content || ''}
defaultOpen={displayConfig.defaultOpen ?? autoExpandTools} defaultOpen={displayConfig.defaultOpen ?? false}
isStreaming={isStreaming} isStreaming={isStreaming}
showRawParameters={mode === 'input' && showRawParameters} showRawParameters={mode === 'input' && showRawParameters}
rawContent={rawToolInput} rawContent={rawToolInput}
@@ -216,7 +214,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
const defaultOpen = displayConfig.defaultOpen !== undefined const defaultOpen = displayConfig.defaultOpen !== undefined
? displayConfig.defaultOpen ? displayConfig.defaultOpen
: autoExpandTools; : false;
const contentProps = displayConfig.getContentProps?.(parsedData, { const contentProps = displayConfig.getContentProps?.(parsedData, {
selectedProject, selectedProject,

View File

@@ -229,7 +229,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${ className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
isSelected isSelected
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30' ? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
: 'dark:hover:bg-gray-750/50 border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600' : 'border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600 dark:hover:bg-gray-700/40'
}`} }`}
> >
{/* Keyboard hint */} {/* Keyboard hint */}
@@ -277,7 +277,7 @@ export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${ className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
isOtherOn isOtherOn
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30' ? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
: 'dark:hover:bg-gray-750/50 border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600' : 'border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600 dark:hover:bg-gray-700/40'
}`} }`}
> >
<kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${ <kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${

View File

@@ -126,10 +126,8 @@ export interface ChatInterfaceProps {
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void; onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void; onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
onShowSettings?: () => void; onShowSettings?: () => void;
autoExpandTools?: boolean;
showRawParameters?: boolean; showRawParameters?: boolean;
showThinking?: boolean; showThinking?: boolean;
autoScrollToBottom?: boolean;
sendByCtrlEnter?: boolean; sendByCtrlEnter?: boolean;
externalMessageUpdate?: number; externalMessageUpdate?: number;
newSessionTrigger?: number; newSessionTrigger?: number;

View File

@@ -1,6 +1,6 @@
import type { ChatMessage } from '../types/types'; import type { ChatMessage } from '../types/types';
export const TOOL_GROUP_THRESHOLD = 3; export const TOOL_GROUP_THRESHOLD = 2;
export interface ToolGroupItem { export interface ToolGroupItem {
_isGroup: true; _isGroup: true;
@@ -19,7 +19,17 @@ function isGroupableToolMessage(message: ChatMessage): message is ChatMessage &
return Boolean(message.isToolUse && message.toolName && !message.isSubagentContainer); return Boolean(message.isToolUse && message.toolName && !message.isSubagentContainer);
} }
export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[] { // Messages that render nothing (e.g. reasoning hidden when showThinking is off)
// shouldn't split an otherwise-continuous run of the same tool — providers like
// Codex interleave hidden reasoning between consecutive tool calls.
function rendersNothing(message: ChatMessage, showThinking: boolean): boolean {
return Boolean(message.isThinking && !showThinking);
}
export function groupConsecutiveTools(
messages: ChatMessage[],
showThinking: boolean = true,
): MessageListItem[] {
const items: MessageListItem[] = []; const items: MessageListItem[] = [];
let index = 0; let index = 0;
@@ -35,13 +45,22 @@ export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[
const run: ChatMessage[] = [message]; const run: ChatMessage[] = [message];
let nextIndex = index + 1; let nextIndex = index + 1;
while ( while (nextIndex < messages.length) {
nextIndex < messages.length && const candidate = messages[nextIndex];
isGroupableToolMessage(messages[nextIndex]) &&
messages[nextIndex].toolName === message.toolName // Skip invisible interleaved messages so they don't break the run.
) { if (rendersNothing(candidate, showThinking)) {
run.push(messages[nextIndex]); nextIndex += 1;
nextIndex += 1; continue;
}
if (isGroupableToolMessage(candidate) && candidate.toolName === message.toolName) {
run.push(candidate);
nextIndex += 1;
continue;
}
break;
} }
if (run.length >= TOOL_GROUP_THRESHOLD) { if (run.length >= TOOL_GROUP_THRESHOLD) {

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ArrowDownIcon } from 'lucide-react';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useWebSocket } from '../../../contexts/WebSocketContext'; import { useWebSocket } from '../../../contexts/WebSocketContext';
@@ -30,10 +31,8 @@ function ChatInterface({
onNavigateToSession, onNavigateToSession,
onSessionEstablished, onSessionEstablished,
onShowSettings, onShowSettings,
autoExpandTools,
showRawParameters, showRawParameters,
showThinking, showThinking,
autoScrollToBottom,
sendByCtrlEnter, sendByCtrlEnter,
externalMessageUpdate, externalMessageUpdate,
newSessionTrigger, newSessionTrigger,
@@ -124,7 +123,6 @@ function ChatInterface({
selectedSession, selectedSession,
ws, ws,
sendMessage, sendMessage,
autoScrollToBottom,
externalMessageUpdate, externalMessageUpdate,
newSessionTrigger, newSessionTrigger,
processingSessions, processingSessions,
@@ -185,7 +183,7 @@ function ChatInterface({
handlePermissionDecision, handlePermissionDecision,
handleGrantToolPermission, handleGrantToolPermission,
handleInputFocusChange, handleInputFocusChange,
isInputFocused: _isInputFocused, isInputFocused,
commandModalPayload, commandModalPayload,
closeCommandModal, closeCommandModal,
showCostModal, showCostModal,
@@ -356,13 +354,27 @@ function ChatInterface({
onFileOpen={onFileOpen} onFileOpen={onFileOpen}
onShowSettings={onShowSettings} onShowSettings={onShowSettings}
onGrantToolPermission={handleGrantToolPermission} onGrantToolPermission={handleGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters} showRawParameters={showRawParameters}
showThinking={showThinking} showThinking={showThinking}
selectedProject={selectedProject} selectedProject={selectedProject}
/> />
<ChatComposer <div className="relative flex-shrink-0">
{isUserScrolledUp && chatMessages.length > 0 && (
<div className="pointer-events-none absolute -top-11 left-0 right-0 z-20 flex justify-center">
<button
type="button"
onClick={scrollToBottomAndReset}
aria-label={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
className="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
>
<ArrowDownIcon className="h-4 w-4" aria-hidden />
</button>
</div>
)}
<ChatComposer
pendingPermissionRequests={pendingPermissionRequests} pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision} handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission} handleGrantToolPermission={handleGrantToolPermission}
@@ -377,9 +389,6 @@ function ChatInterface({
onToggleCommandMenu={handleToggleCommandMenu} onToggleCommandMenu={handleToggleCommandMenu}
hasInput={Boolean(input.trim())} hasInput={Boolean(input.trim())}
onClearInput={handleClearInput} onClearInput={handleClearInput}
isUserScrolledUp={isUserScrolledUp}
hasMessages={chatMessages.length > 0}
onScrollToBottom={scrollToBottomAndReset}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isDragActive={isDragActive} isDragActive={isDragActive}
attachedImages={attachedImages} attachedImages={attachedImages}
@@ -414,6 +423,7 @@ function ChatInterface({
onTextareaPaste={handlePaste} onTextareaPaste={handlePaste}
onTextareaScrollSync={syncInputOverlayScroll} onTextareaScrollSync={syncInputOverlayScroll}
onTextareaInput={handleTextareaInput} onTextareaInput={handleTextareaInput}
isInputFocused={isInputFocused}
onInputFocusChange={handleInputFocusChange} onInputFocusChange={handleInputFocusChange}
placeholder={t('input.placeholder', { placeholder={t('input.placeholder', {
provider: provider:
@@ -430,6 +440,7 @@ function ChatInterface({
isTextareaExpanded={isTextareaExpanded} isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter} sendByCtrlEnter={sendByCtrlEnter}
/> />
</div>
</div> </div>
<QuickSettingsPanel /> <QuickSettingsPanel />

View File

@@ -7,6 +7,7 @@ import type { SessionActivity } from '../../../../hooks/useSessionProtection';
type ActivityIndicatorProps = { type ActivityIndicatorProps = {
activity: SessionActivity | null; activity: SessionActivity | null;
onAbort?: () => void; onAbort?: () => void;
isInputFocused?: boolean;
}; };
const ACTION_KEYS = [ const ACTION_KEYS = [
@@ -18,6 +19,7 @@ const ACTION_KEYS = [
'claudeStatus.actions.reasoning', 'claudeStatus.actions.reasoning',
]; ];
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning']; const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const EXIT_ANIMATION_MS = 220;
/** /**
* Minimal response-in-progress indicator, in the spirit of the inline status * Minimal response-in-progress indicator, in the spirit of the inline status
@@ -26,11 +28,31 @@ const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working',
* session has an entry in the processing map; it disappears the instant that * session has an entry in the processing map; it disappears the instant that
* entry is removed. * entry is removed.
*/ */
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) { export default function ActivityIndicator({ activity, onAbort, isInputFocused = false }: ActivityIndicatorProps) {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const startedAt = activity?.startedAt ?? null; const [renderedActivity, setRenderedActivity] = useState<SessionActivity | null>(activity);
const [isExiting, setIsExiting] = useState(false);
const startedAt = renderedActivity?.startedAt ?? null;
const [elapsedSeconds, setElapsedSeconds] = useState(0); const [elapsedSeconds, setElapsedSeconds] = useState(0);
useEffect(() => {
if (activity) {
setRenderedActivity(activity);
setIsExiting(false);
return;
}
if (!renderedActivity) return;
setIsExiting(true);
const timer = setTimeout(() => {
setRenderedActivity(null);
setIsExiting(false);
}, EXIT_ANIMATION_MS);
return () => clearTimeout(timer);
}, [activity, renderedActivity]);
useEffect(() => { useEffect(() => {
if (startedAt === null) return; if (startedAt === null) return;
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000))); const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
@@ -39,10 +61,10 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
return () => clearInterval(timer); return () => clearInterval(timer);
}, [startedAt]); }, [startedAt]);
if (!activity) return null; if (!renderedActivity) return null;
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] })); const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length]) const label = (renderedActivity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
.replace(/\.+$/, ''); .replace(/\.+$/, '');
const minutes = Math.floor(elapsedSeconds / 60); const minutes = Math.floor(elapsedSeconds / 60);
@@ -50,19 +72,31 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
const elapsedLabel = minutes < 1 const elapsedLabel = minutes < 1
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' }) ? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' }); : t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
const tabSurfaceClassName = [
'chat-activity-tab inline-flex h-8 items-center rounded-b-none rounded-t-lg border border-b-0 bg-card px-3 text-xs transition-all duration-200',
isInputFocused
? 'border-primary/30 shadow-[0_-1px_2px_hsl(var(--foreground)/0.08),1px_0_2px_hsl(var(--foreground)/0.06),-1px_0_2px_hsl(var(--foreground)/0.06)]'
: 'border-border/50 shadow-[0_-1px_1px_hsl(var(--foreground)/0.04),1px_0_1px_hsl(var(--foreground)/0.03),-1px_0_1px_hsl(var(--foreground)/0.03)]',
].join(' ');
return ( return (
<div className="animate-in fade-in mb-2 w-full duration-300"> <div
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1"> className={`pointer-events-none bg-transparent ${
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden /> isExiting ? 'chat-activity-exit' : 'chat-activity-enter'
<Shimmer className="text-xs font-medium">{`${label}`}</Shimmer> }`}
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span> >
<div className="flex items-end justify-between gap-2">
<div className={`${tabSurfaceClassName} gap-2`}>
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
<Shimmer className="font-medium">{`${label}`}</Shimmer>
<span className="tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
</div>
{activity.canInterrupt && onAbort && ( {renderedActivity.canInterrupt && onAbort && (
<button <button
type="button" type="button"
onClick={onAbort} onClick={onAbort}
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" className={`${tabSurfaceClassName} pointer-events-auto gap-1.5 text-muted-foreground hover:bg-card hover:text-destructive`}
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })} aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
> >
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden> <svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>

View File

@@ -11,7 +11,7 @@ import type {
RefObject, RefObject,
TouchEvent, TouchEvent,
} from 'react'; } from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react'; import { ImageIcon, MessageSquareIcon, XIcon, Loader2 } from 'lucide-react';
import { useVoiceInput } from '../../hooks/useVoiceInput'; import { useVoiceInput } from '../../hooks/useVoiceInput';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable'; import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
@@ -68,9 +68,6 @@ interface ChatComposerProps {
onToggleCommandMenu: () => void; onToggleCommandMenu: () => void;
hasInput: boolean; hasInput: boolean;
onClearInput: () => void; onClearInput: () => void;
isUserScrolledUp: boolean;
hasMessages: boolean;
onScrollToBottom: () => void;
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void; onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
isDragActive: boolean; isDragActive: boolean;
attachedImages: File[]; attachedImages: File[];
@@ -101,6 +98,7 @@ interface ChatComposerProps {
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void; onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
onTextareaScrollSync: (target: HTMLTextAreaElement) => void; onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void; onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
isInputFocused?: boolean;
onInputFocusChange?: (focused: boolean) => void; onInputFocusChange?: (focused: boolean) => void;
placeholder: string; placeholder: string;
isTextareaExpanded: boolean; isTextareaExpanded: boolean;
@@ -122,9 +120,6 @@ export default function ChatComposer({
onToggleCommandMenu, onToggleCommandMenu,
hasInput, hasInput,
onClearInput, onClearInput,
isUserScrolledUp,
hasMessages,
onScrollToBottom,
onSubmit, onSubmit,
isDragActive, isDragActive,
attachedImages, attachedImages,
@@ -155,6 +150,7 @@ export default function ChatComposer({
onTextareaPaste, onTextareaPaste,
onTextareaScrollSync, onTextareaScrollSync,
onTextareaInput, onTextareaInput,
isInputFocused = false,
onInputFocusChange, onInputFocusChange,
placeholder, placeholder,
isTextareaExpanded, isTextareaExpanded,
@@ -201,15 +197,18 @@ export default function ChatComposer({
// Hide the thinking/status bar while any permission request is pending // Hide the thinking/status bar while any permission request is pending
const hasPendingPermissions = pendingPermissionRequests.length > 0; const hasPendingPermissions = pendingPermissionRequests.length > 0;
const hasActivityIndicator = Boolean(activity && !hasPendingPermissions);
return ( return (
<div className="chat-composer-shell flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6"> <div className="chat-composer-shell relative flex-shrink-0 px-2 pb-2 pt-0 sm:px-4 sm:pb-4 md:px-4 md:pb-6">
{!hasPendingPermissions && ( {!hasPendingPermissions && (
<ActivityIndicator activity={activity} onAbort={onAbortSession} /> <div className="pointer-events-none absolute bottom-full left-1/2 z-10 w-[calc(100%-1rem)] max-w-[54.25rem] -translate-x-1/2 translate-y-px bg-transparent sm:w-[calc(100%-2rem)]">
<ActivityIndicator activity={activity} onAbort={onAbortSession} isInputFocused={isInputFocused} />
</div>
)} )}
{pendingPermissionRequests.length > 0 && ( {pendingPermissionRequests.length > 0 && (
<div className="mx-auto mb-3 max-w-4xl"> <div className="mx-auto mb-3 max-w-[54.25rem]">
<PermissionRequestsBanner <PermissionRequestsBanner
pendingPermissionRequests={pendingPermissionRequests} pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision} handlePermissionDecision={handlePermissionDecision}
@@ -218,19 +217,7 @@ export default function ChatComposer({
</div> </div>
)} )}
{!hasQuestionPanel && <div className="relative mx-auto max-w-4xl"> {!hasQuestionPanel && <div className="relative mx-auto max-w-[54.25rem]">
{isUserScrolledUp && hasMessages && (
<div className="absolute -top-10 left-0 right-0 z-10 flex justify-center">
<button
type="button"
onClick={onScrollToBottom}
className="flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
>
<ArrowDownIcon className="h-4 w-4" />
</button>
</div>
)}
{showFileDropdown && filteredFiles.length > 0 && ( {showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md"> <div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
{filteredFiles.map((file, index) => ( {filteredFiles.map((file, index) => (
@@ -271,7 +258,10 @@ export default function ChatComposer({
<PromptInput <PromptInput
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
status={isLoading ? 'streaming' : 'ready'} status={isLoading ? 'streaming' : 'ready'}
className={isTextareaExpanded ? 'chat-input-expanded' : ''} className={[
isTextareaExpanded ? 'chat-input-expanded' : '',
hasActivityIndicator ? 'rounded-t-none' : '',
].filter(Boolean).join(' ')}
{...getRootProps()} {...getRootProps()}
> >
{isDragActive && ( {isDragActive && (
@@ -349,7 +339,7 @@ export default function ChatComposer({
<button <button
type="button" type="button"
onClick={onModeSwitch} onClick={onModeSwitch}
className={`rounded-lg border p-2 text-xs font-medium transition-all duration-200 sm:px-2.5 sm:py-1 ${ className={`inline-flex h-8 items-center rounded-lg border px-2 text-xs font-medium transition-all duration-200 sm:px-2.5 ${
permissionMode === 'default' permissionMode === 'default'
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted' ? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
: permissionMode === 'acceptEdits' : permissionMode === 'acceptEdits'

View File

@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { memo, useCallback, useMemo, useRef } from 'react'; import { memo, useCallback, useMemo } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { Dispatch, RefObject, SetStateAction } from 'react';
import type { ChatMessage } from '../../types/types'; import type { ChatMessage } from '../../types/types';
@@ -15,6 +15,7 @@ import { groupConsecutiveTools, isToolGroupItem } from '../../utils/toolGrouping
import MessageComponent from './MessageComponent'; import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState'; import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import ToolGroupContainer from './ToolGroupContainer'; import ToolGroupContainer from './ToolGroupContainer';
import LoadAllMessagesOverlay from './LoadAllMessagesOverlay';
interface ChatMessagesPaneProps { interface ChatMessagesPaneProps {
scrollContainerRef: RefObject<HTMLDivElement>; scrollContainerRef: RefObject<HTMLDivElement>;
@@ -61,7 +62,6 @@ interface ChatMessagesPaneProps {
onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void; onShowSettings?: () => void;
onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean }; onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
autoExpandTools?: boolean;
showRawParameters?: boolean; showRawParameters?: boolean;
showThinking?: boolean; showThinking?: boolean;
selectedProject: Project; selectedProject: Project;
@@ -111,48 +111,59 @@ function ChatMessagesPane({
onFileOpen, onFileOpen,
onShowSettings, onShowSettings,
onGrantToolPermission, onGrantToolPermission,
autoExpandTools,
showRawParameters, showRawParameters,
showThinking, showThinking,
selectedProject, selectedProject,
}: ChatMessagesPaneProps) { }: ChatMessagesPaneProps) {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap()); const groupedVisibleMessages = useMemo(
const allocatedKeysRef = useRef<Set<string>>(new Set()); () => groupConsecutiveTools(visibleMessages, Boolean(showThinking)),
const generatedMessageKeyCounterRef = useRef(0); [visibleMessages, showThinking],
const groupedVisibleMessages = useMemo(() => groupConsecutiveTools(visibleMessages), [visibleMessages]); );
// Keep keys stable across prepends so existing MessageComponent instances retain local state. // Stable, deterministic keys for the messages rendered this pass.
const getMessageKey = useCallback((message: ChatMessage) => { //
const existingKey = messageKeyMapRef.current.get(message); // `normalizedToChatMessages` rebuilds fresh ChatMessage objects on every store
if (existingKey) { // update, so caching keys by object identity (or via a cross-render allocation
return existingKey; // Set) minted a brand-new key for the *same* logical message on each prepend —
// remounting the whole list, which disconnects the scroll-restore anchor and
// reflows heights, jumping the viewport to the bottom. Deriving keys purely
// from this render's ordered messages (intrinsic key, disambiguated by
// occurrence index on collision) yields the same key for the same message
// order, so React preserves existing DOM nodes and component state on prepend.
const messageKeyMap = useMemo(() => {
const keys = new WeakMap<ChatMessage, string>();
const occurrences = new Map<string, number>();
const assign = (message: ChatMessage) => {
const intrinsicKey = getIntrinsicMessageKey(message) ?? 'message-generated';
const seen = occurrences.get(intrinsicKey) ?? 0;
occurrences.set(intrinsicKey, seen + 1);
keys.set(message, seen === 0 ? intrinsicKey : `${intrinsicKey}__${seen}`);
};
for (const item of groupedVisibleMessages) {
if (isToolGroupItem(item)) {
item.messages.forEach(assign);
} else {
assign(item);
}
} }
return keys;
}, [groupedVisibleMessages]);
const intrinsicKey = getIntrinsicMessageKey(message); const getMessageKey = useCallback(
let candidateKey = intrinsicKey; (message: ChatMessage) =>
messageKeyMap.get(message) ?? getIntrinsicMessageKey(message) ?? 'message-generated',
if (!candidateKey || allocatedKeysRef.current.has(candidateKey)) { [messageKeyMap],
do { );
generatedMessageKeyCounterRef.current += 1;
candidateKey = intrinsicKey
? `${intrinsicKey}-${generatedMessageKeyCounterRef.current}`
: `message-generated-${generatedMessageKeyCounterRef.current}`;
} while (allocatedKeysRef.current.has(candidateKey));
}
allocatedKeysRef.current.add(candidateKey);
messageKeyMapRef.current.set(message, candidateKey);
return candidateKey;
}, []);
return ( return (
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
onWheel={onWheel} onWheel={onWheel}
onTouchMove={onTouchMove} onTouchMove={onTouchMove}
className="chat-messages-pane relative min-h-0 flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4" className="chat-messages-pane relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-3 sm:py-4"
> >
<div className="mx-auto w-full max-w-[54.25rem] space-y-3 px-4 sm:space-y-4">
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? ( {(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
<div className="mt-8 text-center text-gray-500 dark:text-gray-400"> <div className="mt-8 text-center text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-center space-x-2"> <div className="flex items-center justify-center space-x-2">
@@ -208,35 +219,13 @@ function ChatMessagesPane({
</div> </div>
)} )}
{/* Floating "Load all messages" overlay */} <LoadAllMessagesOverlay
{(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && ( showLoadAllOverlay={showLoadAllOverlay}
<div className="pointer-events-none sticky top-2 z-20 flex justify-center"> isLoadingAllMessages={isLoadingAllMessages}
{loadAllJustFinished ? ( loadAllJustFinished={loadAllJustFinished}
<div className="flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500"> totalMessages={totalMessages}
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> onLoadAllMessages={loadAllMessages}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /> />
</svg>
<span>{t('session.messages.allLoaded')}</span>
</div>
) : (
<button
className="pointer-events-auto flex items-center space-x-2 rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:bg-blue-700 disabled:cursor-wait disabled:opacity-75 dark:bg-blue-500 dark:hover:bg-blue-600"
onClick={loadAllMessages}
disabled={isLoadingAllMessages}
>
{isLoadingAllMessages && (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
)}
<span>
{isLoadingAllMessages
? t('session.messages.loadingAll')
: <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}</>
}
</span>
</button>
)}
</div>
)}
{/* Legacy message count indicator (for non-paginated view) */} {/* Legacy message count indicator (for non-paginated view) */}
{!hasMoreMessages && chatMessages.length > visibleMessageCount && ( {!hasMoreMessages && chatMessages.length > visibleMessageCount && (
@@ -273,7 +262,6 @@ function ChatMessagesPane({
onFileOpen={onFileOpen} onFileOpen={onFileOpen}
onShowSettings={onShowSettings} onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission} onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters} showRawParameters={showRawParameters}
showThinking={showThinking} showThinking={showThinking}
selectedProject={selectedProject} selectedProject={selectedProject}
@@ -294,7 +282,6 @@ function ChatMessagesPane({
onFileOpen={onFileOpen} onFileOpen={onFileOpen}
onShowSettings={onShowSettings} onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission} onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters} showRawParameters={showRawParameters}
showThinking={showThinking} showThinking={showThinking}
selectedProject={selectedProject} selectedProject={selectedProject}
@@ -305,6 +292,7 @@ function ChatMessagesPane({
})()} })()}
</> </>
)} )}
</div>
</div> </div>
); );
} }

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react'; import { createPortal } from 'react-dom';
import type { CSSProperties, ReactElement } from 'react';
import { import {
CornerDownLeft, CornerDownLeft,
Folder, Folder,
@@ -77,6 +78,7 @@ const namespaceAccentClasses: Record<string, string> = {
const MENU_EDGE_GAP = 16; const MENU_EDGE_GAP = 16;
const MENU_MAX_HEIGHT = 360; const MENU_MAX_HEIGHT = 360;
const MENU_MIN_HEIGHT = 160;
const getCommandKey = (command: CommandMenuCommand) => const getCommandKey = (command: CommandMenuCommand) =>
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`; `${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
@@ -92,8 +94,9 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return { position: 'fixed', top: '16px', left: '16px' }; return { position: 'fixed', top: '16px', left: '16px' };
} }
const maxAnchorBottom = Math.max(MENU_EDGE_GAP, window.innerHeight - MENU_EDGE_GAP - MENU_MIN_HEIGHT);
if (window.innerWidth < 640) { if (window.innerWidth < 640) {
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90); const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
return { return {
position: 'fixed', position: 'fixed',
bottom: `${anchorBottom}px`, bottom: `${anchorBottom}px`,
@@ -104,7 +107,7 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`, maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
}; };
} }
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90); const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
const clampedLeft = Math.max( const clampedLeft = Math.max(
MENU_EDGE_GAP, MENU_EDGE_GAP,
Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP), Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP),
@@ -216,12 +219,14 @@ export default function CommandMenu({
: ['builtin', 'skill', 'project', 'user', 'other']; : ['builtin', 'skill', 'project', 'user', 'other'];
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace)); const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]); const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
const renderInPortal = (node: ReactElement) =>
typeof document === 'undefined' ? node : createPortal(node, document.body);
if (commands.length === 0) { if (commands.length === 0) {
return ( return renderInPortal(
<div <div
ref={menuRef} ref={menuRef}
className="command-menu command-menu-empty border border-gray-200 bg-white/95 text-sm text-gray-500 dark:border-gray-700/80 dark:bg-gray-900/95 dark:text-gray-400" className="command-menu command-menu-empty border border-border bg-popover/95 text-sm text-muted-foreground"
style={{ style={{
...menuBaseStyle, ...menuBaseStyle,
...menuPosition, ...menuPosition,
@@ -237,20 +242,20 @@ export default function CommandMenu({
); );
} }
return ( return renderInPortal(
<div <div
ref={menuRef} ref={menuRef}
role="listbox" role="listbox"
aria-label="Available commands" aria-label="Available commands"
className="command-menu border border-gray-200/90 bg-white/95 text-gray-900 dark:border-slate-700/80 dark:bg-slate-950/95 dark:text-slate-100" className="command-menu border border-border bg-popover/95 text-popover-foreground"
style={{ ...menuBaseStyle, ...menuPosition, opacity: 1, transform: 'translateY(0)' }} style={{ ...menuBaseStyle, ...menuPosition, opacity: 1, transform: 'translateY(0)' }}
> >
{orderedNamespaces.map((namespace) => ( {orderedNamespaces.map((namespace) => (
<div key={namespace} className="command-group"> <div key={namespace} className="command-group">
{orderedNamespaces.length > 1 && ( {orderedNamespaces.length > 1 && (
<div className="flex items-center justify-between px-2 pb-1.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400"> <div className="flex items-center justify-between px-2 pb-1.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
<span>{namespaceLabels[namespace] || namespace}</span> <span>{namespaceLabels[namespace] || namespace}</span>
<span className="rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] text-gray-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400"> <span className="rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{(groupedCommands[namespace] || []).length} {(groupedCommands[namespace] || []).length}
</span> </span>
</div> </div>
@@ -268,15 +273,15 @@ export default function CommandMenu({
aria-selected={isSelected} aria-selected={isSelected}
className={`command-item group relative mb-1 flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-all ${ className={`command-item group relative mb-1 flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-all ${
isSelected isSelected
? 'border-sky-200 bg-sky-50 shadow-sm dark:border-cyan-400/30 dark:bg-cyan-400/10' ? 'border-primary/30 bg-primary/10 shadow-sm'
: 'border-transparent bg-transparent hover:border-gray-200 hover:bg-gray-50/90 dark:hover:border-slate-700 dark:hover:bg-slate-900/80' : 'border-transparent bg-transparent hover:border-border hover:bg-accent'
}`} }`}
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)} onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)} onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
> >
{isSelected && ( {isSelected && (
<span className="absolute bottom-1.5 left-1.5 top-1.5 w-0.5 rounded-full bg-sky-500 dark:bg-cyan-300" /> <span className="absolute bottom-1.5 left-1.5 top-1.5 w-0.5 rounded-full bg-primary" />
)} )}
<span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border ${accentClass}`}> <span className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border ${accentClass}`}>
<NamespaceIcon aria-hidden="true" size={14} strokeWidth={2.2} /> <NamespaceIcon aria-hidden="true" size={14} strokeWidth={2.2} />
@@ -284,20 +289,20 @@ export default function CommandMenu({
<div className="min-w-0 flex-1 pr-1"> <div className="min-w-0 flex-1 pr-1">
<div className={`flex min-w-0 items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}> <div className={`flex min-w-0 items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
<span <span
className="min-w-0 truncate font-mono text-[13px] font-semibold text-gray-950 dark:text-slate-50" className="min-w-0 truncate font-mono text-[13px] font-semibold text-foreground"
title={command.name} title={command.name}
> >
{command.name} {command.name}
</span> </span>
{command.metadata?.type && ( {command.metadata?.type && (
<span className="command-metadata-badge shrink-0 rounded border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-500 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300"> <span className="command-metadata-badge shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground shadow-sm">
{command.metadata.type} {command.metadata.type}
</span> </span>
)} )}
</div> </div>
{command.description && ( {command.description && (
<div <div
className="truncate whitespace-nowrap text-[12px] leading-4 text-gray-500 dark:text-slate-400" className="truncate whitespace-nowrap text-[12px] leading-4 text-muted-foreground"
title={command.description} title={command.description}
> >
{command.description} {command.description}
@@ -305,7 +310,7 @@ export default function CommandMenu({
)} )}
</div> </div>
{isSelected && ( {isSelected && (
<span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded border border-sky-200 bg-white text-sky-600 shadow-sm dark:border-cyan-400/30 dark:bg-slate-950 dark:text-cyan-200"> <span className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded border border-primary/30 bg-card text-primary shadow-sm">
<CornerDownLeft aria-hidden="true" size={13} strokeWidth={2.2} /> <CornerDownLeft aria-hidden="true" size={13} strokeWidth={2.2} />
</span> </span>
)} )}

View File

@@ -565,46 +565,41 @@ export default function CommandResultModal({
<DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle> <DialogTitle>{activeMeta?.title || 'Command Result'}</DialogTitle>
<div <div
className={`relative shrink-0 overflow-hidden border-b border-border/70 bg-gradient-to-br from-primary/15 via-background to-muted/40 ${ className={`flex shrink-0 items-start justify-between gap-3 border-b border-border bg-popover ${
isModelsModal ? 'px-4 pb-3 pt-3 sm:px-5 sm:pb-4 sm:pt-4' : 'px-4 pb-4 pt-4 sm:px-6 sm:pb-5 sm:pt-5' isModelsModal ? 'px-4 py-3 sm:px-5 sm:py-4' : 'px-4 py-4 sm:px-6 sm:py-5'
}`} }`}
> >
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-primary/20 blur-3xl" /> <div className="flex min-w-0 items-center gap-3">
<div className="pointer-events-none absolute right-0 top-0 h-full w-1/2 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.16),transparent_58%)]" /> <div
className={`flex shrink-0 items-center justify-center rounded-xl border border-border bg-muted text-foreground ${
<div className="relative flex items-start justify-between gap-3"> isModelsModal ? 'h-9 w-9' : 'h-10 w-10'
<div className="flex min-w-0 items-start gap-3 sm:items-center"> }`}
<div
className={`rounded-2xl border border-primary/30 bg-primary/10 text-primary shadow-sm ${
isModelsModal ? 'p-2.5' : 'p-3'
}`}
>
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
</div>
<div className="min-w-0">
<p className="text-[12px] font-bold uppercase tracking-[0.22em] text-primary">
{activeMeta?.eyebrow}
</p>
<p className={`mt-1 font-semibold tracking-tight text-foreground ${isModelsModal ? 'text-xl sm:text-2xl' : 'text-xl sm:text-2xl'}`}>
{activeMeta?.title}
</p>
<p className={`mt-1 max-w-2xl ${isModelsModal ? 'text-sm leading-5 text-foreground/75' : 'text-sm leading-5 text-muted-foreground'}`}>
{activeMeta?.subtitle}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:bg-background/70 hover:text-foreground"
aria-label="Close command result modal"
> >
<X className="h-4 w-4" /> <HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
</Button> </div>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{activeMeta?.eyebrow}
</p>
<p className="mt-0.5 text-lg font-semibold tracking-tight text-foreground sm:text-xl">
{activeMeta?.title}
</p>
<p className="mt-0.5 max-w-2xl text-sm leading-5 text-muted-foreground">
{activeMeta?.subtitle}
</p>
</div>
</div> </div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 shrink-0 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close command result modal"
>
<X className="h-4 w-4" />
</Button>
</div> </div>
<div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5"> <div className="settings-content-enter min-h-0 flex-1 overflow-hidden px-4 py-4 sm:px-6 sm:py-5">

View File

@@ -0,0 +1,68 @@
import { useTranslation } from 'react-i18next';
const loadAllOverlayAnimationStyle = `
@keyframes loadAllOverlayAutoFade {
0%, 80% { opacity: 1; }
100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.load-all-overlay-auto-fade {
animation: none !important;
}
}
`;
interface LoadAllMessagesOverlayProps {
showLoadAllOverlay: boolean;
isLoadingAllMessages: boolean;
loadAllJustFinished: boolean;
totalMessages: number;
onLoadAllMessages: () => void;
}
export default function LoadAllMessagesOverlay({
showLoadAllOverlay,
isLoadingAllMessages,
loadAllJustFinished,
totalMessages,
onLoadAllMessages,
}: LoadAllMessagesOverlayProps) {
const { t } = useTranslation('chat');
if (!showLoadAllOverlay && !isLoadingAllMessages && !loadAllJustFinished) {
return null;
}
return (
<div
className={`pointer-events-none sticky top-2 z-20 flex justify-center ${!isLoadingAllMessages ? 'load-all-overlay-auto-fade' : ''}`}
style={!isLoadingAllMessages ? { animation: 'loadAllOverlayAutoFade 2500ms ease forwards' } : undefined}
>
<style>{loadAllOverlayAnimationStyle}</style>
{loadAllJustFinished ? (
<div className="flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
<span>{t('session.messages.allLoaded')}</span>
</div>
) : (
<button
className="pointer-events-auto flex items-center space-x-2 rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:bg-blue-700 disabled:cursor-wait disabled:opacity-75 dark:bg-blue-500 dark:hover:bg-blue-600"
onClick={onLoadAllMessages}
disabled={isLoadingAllMessages}
>
{isLoadingAllMessages && (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
)}
<span>
{isLoadingAllMessages
? t('session.messages.loadingAll')
: <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}</>}
</span>
</button>
)}
</div>
);
}

View File

@@ -4,11 +4,12 @@ import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { normalizeInlineCodeFences } from '../../utils/chatFormatting'; import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
import { copyTextToClipboard } from '../../../../utils/clipboard'; import { copyTextToClipboard } from '../../../../utils/clipboard';
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext'; import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
import { useTheme } from '../../../../contexts/ThemeContext';
type MarkdownProps = { type MarkdownProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -59,6 +60,7 @@ type CodeBlockProps = {
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => { const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const { isDarkMode } = useTheme();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const raw = Array.isArray(children) ? children.join('') : String(children ?? ''); const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(raw); const looksMultiline = /[\r\n]/.test(raw);
@@ -96,7 +98,7 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
} }
}) })
} }
className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 focus:opacity-100 active:opacity-100 group-hover:opacity-100" className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 px-2 py-1 text-xs text-foreground/80 opacity-0 transition-opacity hover:bg-muted focus:opacity-100 active:opacity-100 group-hover:opacity-100"
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')} title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')} aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
> >
@@ -132,17 +134,20 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
<SyntaxHighlighter <SyntaxHighlighter
language={language} language={language}
style={oneDark} style={isDarkMode ? oneDark : oneLight}
customStyle={{ customStyle={{
margin: 0, margin: 0,
borderRadius: '0.5rem', borderRadius: '0.75rem',
fontSize: '0.875rem', fontSize: '0.875rem',
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem', padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
// ChatGPT-style soft grey block in light mode; keep oneDark's own bg in dark.
...(isDarkMode ? {} : { background: 'hsl(var(--muted))' }),
}} }}
codeTagProps={{ codeTagProps={{
style: { style: {
fontFamily: fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
...(isDarkMode ? {} : { background: 'transparent' }),
}, },
}} }}
> >
@@ -154,6 +159,10 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
const markdownComponents = { const markdownComponents = {
code: CodeBlock, code: CodeBlock,
// CodeBlock renders its own syntax-highlighted <pre>; this passthrough stops
// react-markdown (and Tailwind Typography) from wrapping it in a second,
// dark-themed <pre> shell that would frame the block.
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
blockquote: ({ children }: { children?: React.ReactNode }) => ( blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400"> <blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
{children} {children}

View File

@@ -1,4 +1,4 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
@@ -30,7 +30,6 @@ type MessageComponentProps = {
onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void; onShowSettings?: () => void;
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined; onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
autoExpandTools?: boolean;
showRawParameters?: boolean; showRawParameters?: boolean;
showThinking?: boolean; showThinking?: boolean;
selectedProject?: Project | null; selectedProject?: Project | null;
@@ -45,7 +44,7 @@ type InteractiveOption = {
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']); const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => { const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type && const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') || ((prevMessage.type === 'assistant') ||
@@ -53,7 +52,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
(prevMessage.type === 'tool') || (prevMessage.type === 'tool') ||
(prevMessage.type === 'error')); (prevMessage.type === 'error'));
const messageRef = useRef<HTMLDivElement | null>(null); const messageRef = useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const userCopyContent = String(message.content || ''); const userCopyContent = String(message.content || '');
const formattedMessageContent = useMemo( const formattedMessageContent = useMemo(
() => formatUsageLimitText(String(message.content || '')), () => formatUsageLimitText(String(message.content || '')),
@@ -72,32 +70,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
!message.isThinking; !message.isThinking;
useEffect(() => {
const node = messageRef.current;
if (!autoExpandTools || !node || !message.isToolUse) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isExpanded) {
setIsExpanded(true);
const details = node.querySelectorAll<HTMLDetailsElement>('details');
details.forEach((detail) => {
detail.open = true;
});
}
});
},
{ threshold: 0.1 }
);
observer.observe(node);
return () => {
observer.unobserve(node);
};
}, [autoExpandTools, isExpanded, message.isToolUse]);
const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]); const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking); const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
@@ -115,7 +87,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
/* User message bubble on the right */ /* User message bubble on the right */
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl"> <div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4"> <div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
<div dir="auto" className="whitespace-pre-wrap break-words text-sm"> <div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
{message.content} {message.content}
</div> </div>
{message.images && message.images.length > 0 && ( {message.images && message.images.length > 0 && (
@@ -166,7 +138,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
🔧 🔧
</div> </div>
) : ( ) : (
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-white"> <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-foreground">
<SessionProviderLogo provider={provider} className="h-full w-full" /> <SessionProviderLogo provider={provider} className="h-full w-full" />
</div> </div>
)} )}
@@ -194,7 +166,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<> <>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-col"> <div className="flex flex-col">
<Markdown className="prose prose-sm max-w-none dark:prose-invert"> <Markdown className="prose prose-sm max-w-none font-serif dark:prose-invert">
{String(message.displayText || '')} {String(message.displayText || '')}
</Markdown> </Markdown>
</div> </div>
@@ -210,7 +182,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
onFileOpen={onFileOpen} onFileOpen={onFileOpen}
createDiff={createDiff} createDiff={createDiff}
selectedProject={selectedProject} selectedProject={selectedProject}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters} showRawParameters={showRawParameters}
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined} rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
isSubagentContainer={message.isSubagentContainer} isSubagentContainer={message.isSubagentContainer}
@@ -233,7 +204,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span> <span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
</div> </div>
<div className="relative text-sm text-red-900 dark:text-red-100"> <div className="relative text-sm text-red-900 dark:text-red-100">
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert"> <Markdown className="prose prose-sm prose-red max-w-none font-serif dark:prose-invert">
{String(message.toolResult.content || '')} {String(message.toolResult.content || '')}
</Markdown> </Markdown>
</div> </div>
@@ -250,7 +221,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
onFileOpen={onFileOpen} onFileOpen={onFileOpen}
createDiff={createDiff} createDiff={createDiff}
selectedProject={selectedProject} selectedProject={selectedProject}
autoExpandTools={autoExpandTools}
/> />
</div> </div>
) )
@@ -342,7 +312,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<Reasoning defaultOpen={false}> <Reasoning defaultOpen={false}>
<ReasoningTrigger /> <ReasoningTrigger />
<ReasoningContent> <ReasoningContent>
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert"> <Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
{message.content} {message.content}
</Markdown> </Markdown>
<div className="mt-3 flex items-center text-[11px]"> <div className="mt-3 flex items-center text-[11px]">
@@ -377,15 +347,15 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
return ( return (
<div className="my-2"> <div className="my-2">
<div className="mb-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400"> <div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg> </svg>
<span className="font-medium">{t('json.response')}</span> <span className="font-medium">{t('json.response')}</span>
</div> </div>
<div className="overflow-hidden rounded-lg border border-gray-600/30 bg-gray-800 dark:border-gray-700 dark:bg-gray-900"> <div className="overflow-hidden rounded-lg border border-border bg-muted">
<pre className="overflow-x-auto p-4"> <pre className="overflow-x-auto p-4">
<code className="block whitespace-pre font-mono text-sm text-gray-100 dark:text-gray-200"> <code className="block whitespace-pre font-mono text-sm text-foreground">
{formatted} {formatted}
</code> </code>
</pre> </pre>
@@ -399,7 +369,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
// Normal rendering for non-JSON content // Normal rendering for non-JSON content
return message.type === 'assistant' ? ( return message.type === 'assistant' ? (
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert"> <Markdown className="prose prose-sm prose-gray max-w-none font-serif dark:prose-invert">
{content} {content}
</Markdown> </Markdown>
) : ( ) : (

View File

@@ -1,4 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { copyTextToClipboard } from '../../../../utils/clipboard'; import { copyTextToClipboard } from '../../../../utils/clipboard';
@@ -49,9 +51,32 @@ const MessageCopyControl = ({
const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat); const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
const dropdownRef = useRef<HTMLDivElement | null>(null); const dropdownRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// The dropdown is rendered in a portal so it escapes the chat message's
// `contain: paint` box (which would otherwise clip it). Anchor it to the
// trigger, flipping above when there isn't room below.
const openDropdown = () => {
const rect = triggerRef.current?.getBoundingClientRect();
if (rect) {
const ESTIMATED_MENU_HEIGHT = 84;
const openUp = rect.bottom + ESTIMATED_MENU_HEIGHT + 8 > window.innerHeight;
setMenuStyle({
position: 'fixed',
right: Math.max(8, window.innerWidth - rect.right),
zIndex: 1000,
...(openUp
? { bottom: window.innerHeight - rect.top + 4 }
: { top: rect.bottom + 4 }),
});
}
setIsDropdownOpen(true);
};
const copyFormatOptions: CopyFormatOption[] = useMemo( const copyFormatOptions: CopyFormatOption[] = useMemo(
() => [ () => [
{ {
@@ -83,18 +108,28 @@ const MessageCopyControl = ({
}, [defaultFormat]); }, [defaultFormat]);
useEffect(() => { useEffect(() => {
// Close the dropdown when clicking anywhere outside this control. if (!isDropdownOpen) return;
// Close when clicking outside both the control and the portaled menu.
const closeOnOutsideClick = (event: MouseEvent) => { const closeOnOutsideClick = (event: MouseEvent) => {
if (!isDropdownOpen) return;
const target = event.target as Node; const target = event.target as Node;
if (dropdownRef.current && !dropdownRef.current.contains(target)) { if (dropdownRef.current?.contains(target) || menuRef.current?.contains(target)) {
setIsDropdownOpen(false); return;
} }
setIsDropdownOpen(false);
}; };
// The menu is fixed-positioned; close it if the page scrolls so it can't
// detach from the trigger.
const closeOnScroll = () => setIsDropdownOpen(false);
window.addEventListener('mousedown', closeOnOutsideClick); window.addEventListener('mousedown', closeOnOutsideClick);
window.addEventListener('scroll', closeOnScroll, true);
window.addEventListener('resize', closeOnScroll);
return () => { return () => {
window.removeEventListener('mousedown', closeOnOutsideClick); window.removeEventListener('mousedown', closeOnOutsideClick);
window.removeEventListener('scroll', closeOnScroll, true);
window.removeEventListener('resize', closeOnScroll);
}; };
}, [isDropdownOpen]); }, [isDropdownOpen]);
@@ -170,8 +205,9 @@ const MessageCopyControl = ({
{canSelectCopyFormat && ( {canSelectCopyFormat && (
<> <>
<button <button
ref={triggerRef}
type="button" type="button"
onClick={() => setIsDropdownOpen((prev) => !prev)} onClick={() => (isDropdownOpen ? setIsDropdownOpen(false) : openDropdown())}
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`} className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })} aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })} title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
@@ -186,8 +222,12 @@ const MessageCopyControl = ({
</svg> </svg>
</button> </button>
{isDropdownOpen && ( {isDropdownOpen && createPortal(
<div className="absolute left-auto top-full z-30 mt-1 min-w-36 rounded-md border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"> <div
ref={menuRef}
style={menuStyle}
className="min-w-36 rounded-md border border-border bg-popover p-1 shadow-lg"
>
{copyFormatOptions.map((option) => { {copyFormatOptions.map((option) => {
const isSelected = option.format === selectedFormat; const isSelected = option.format === selectedFormat;
return ( return (
@@ -196,15 +236,16 @@ const MessageCopyControl = ({
type="button" type="button"
onClick={() => handleFormatChange(option.format)} onClick={() => handleFormatChange(option.format)}
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100' ? 'bg-accent text-foreground'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60' : 'text-foreground hover:bg-accent'
}`} }`}
> >
<span className="block text-xs font-medium">{option.label}</span> <span className="block text-xs font-medium">{option.label}</span>
</button> </button>
); );
})} })}
</div> </div>,
document.body,
)} )}
</> </>
)} )}

View File

@@ -186,7 +186,7 @@ export default function ProviderSelectionEmptyState({
if (!selectedSession && !currentSessionId) { if (!selectedSession && !currentSessionId) {
return ( return (
<div className="flex h-full items-center justify-center px-4"> <div className="flex h-full items-center justify-center px-4">
<div className="w-full max-w-md"> <div className="w-full max-w-[34.25rem]">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl"> <h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
{t("providerSelection.title")} {t("providerSelection.title")}
@@ -352,7 +352,7 @@ export default function ProviderSelectionEmptyState({
if (selectedSession) { if (selectedSession) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="max-w-md px-6 text-center"> <div className="max-w-[34.25rem] px-6 text-center">
<p className="mb-1.5 text-lg font-semibold text-foreground"> <p className="mb-1.5 text-lg font-semibold text-foreground">
{t("session.continue.title")} {t("session.continue.title")}
</p> </p>

View File

@@ -43,7 +43,7 @@ export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryP
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5" className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5"
title={`${usedTokens.toLocaleString()} tokens used`} title={`${usedTokens.toLocaleString()} tokens used`}
aria-label="Show token usage" aria-label="Show token usage"
> >

View File

@@ -22,7 +22,6 @@ interface ToolGroupContainerProps {
onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void; onShowSettings?: () => void;
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined; onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
autoExpandTools?: boolean;
showRawParameters?: boolean; showRawParameters?: boolean;
showThinking?: boolean; showThinking?: boolean;
selectedProject?: Project | null; selectedProject?: Project | null;
@@ -66,7 +65,6 @@ export default function ToolGroupContainer({
onFileOpen, onFileOpen,
onShowSettings, onShowSettings,
onGrantToolPermission, onGrantToolPermission,
autoExpandTools,
showRawParameters, showRawParameters,
showThinking, showThinking,
selectedProject, selectedProject,
@@ -133,7 +131,6 @@ export default function ToolGroupContainer({
onFileOpen={onFileOpen} onFileOpen={onFileOpen}
onShowSettings={onShowSettings} onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission} onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters} showRawParameters={showRawParameters}
showThinking={showThinking} showThinking={showThinking}
selectedProject={selectedProject} selectedProject={selectedProject}

View File

@@ -1,5 +1,4 @@
export const CODE_EDITOR_STORAGE_KEYS = { export const CODE_EDITOR_STORAGE_KEYS = {
theme: 'codeEditorTheme',
wordWrap: 'codeEditorWordWrap', wordWrap: 'codeEditorWordWrap',
showMinimap: 'codeEditorShowMinimap', showMinimap: 'codeEditorShowMinimap',
lineNumbers: 'codeEditorLineNumbers', lineNumbers: 'codeEditorLineNumbers',
@@ -7,7 +6,6 @@ export const CODE_EDITOR_STORAGE_KEYS = {
} as const; } as const;
export const CODE_EDITOR_DEFAULTS = { export const CODE_EDITOR_DEFAULTS = {
isDarkMode: true,
wordWrap: false, wordWrap: false,
minimapEnabled: true, minimapEnabled: true,
showLineNumbers: true, showLineNumbers: true,

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { api } from '../../../utils/api'; import { api } from '../../../utils/api';
import type { CodeEditorFile } from '../types/types'; import type { CodeEditorFile } from '../types/types';
import { isBinaryFile } from '../utils/binaryFile'; import { isBinaryFile } from '../utils/binaryFile';
import { getPreviewKind } from '../utils/previewableFile';
type UseCodeEditorDocumentParams = { type UseCodeEditorDocumentParams = {
file: CodeEditorFile; file: CodeEditorFile;
@@ -23,6 +24,9 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [isBinary, setIsBinary] = useState(false); const [isBinary, setIsBinary] = useState(false);
// Some binaries (images, PDFs, audio, video) can be rendered natively, so the
// editor shows an inline preview instead of the generic binary placeholder.
const previewKind = getPreviewKind(file.name);
// `fileProjectId` is the DB primary key passed down from the editor sidebar; // `fileProjectId` is the DB primary key passed down from the editor sidebar;
// the fallback to `projectPath` preserves older callers that didn't yet // the fallback to `projectPath` preserves older callers that didn't yet
// propagate the identifier. // propagate the identifier.
@@ -38,8 +42,19 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
setLoading(true); setLoading(true);
setIsBinary(false); setIsBinary(false);
// Natively previewable media (image/pdf/audio/video) is rendered by
// CodeEditorMediaPreview, so there is nothing to read as text here.
// Clear any buffer left over from a previously opened text file so a
// stray save can't write stale content over the binary file.
if (getPreviewKind(file.name)) {
setContent('');
setLoading(false);
return;
}
// Check if file is binary by extension // Check if file is binary by extension
if (isBinaryFile(file.name)) { if (isBinaryFile(file.name)) {
setContent('');
setIsBinary(true); setIsBinary(true);
setLoading(false); setLoading(false);
return; return;
@@ -76,6 +91,12 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]); }, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
// Preview-only and binary files have no editable text buffer; never write
// them back (e.g. via Cmd/Ctrl+S) or we'd corrupt the file on disk.
if (previewKind || isBinaryFile(fileName)) {
return;
}
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
@@ -109,7 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [content, filePath, fileProjectId]); }, [content, filePath, fileProjectId, previewKind, fileName]);
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
const blob = new Blob([content], { type: 'text/plain' }); const blob = new Blob([content], { type: 'text/plain' });
@@ -134,6 +155,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
saveSuccess, saveSuccess,
saveError, saveError,
isBinary, isBinary,
previewKind,
fileProjectId,
handleSave, handleSave,
handleDownload, handleDownload,
}; };

View File

@@ -5,15 +5,6 @@ import {
CODE_EDITOR_STORAGE_KEYS, CODE_EDITOR_STORAGE_KEYS,
} from '../constants/settings'; } from '../constants/settings';
const readTheme = () => {
const savedTheme = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.theme);
if (!savedTheme) {
return CODE_EDITOR_DEFAULTS.isDarkMode;
}
return savedTheme === 'dark';
};
const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => { const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {
const value = localStorage.getItem(storageKey); const value = localStorage.getItem(storageKey);
if (value === null) { if (value === null) {
@@ -33,7 +24,6 @@ const readFontSize = () => {
}; };
export const useCodeEditorSettings = () => { export const useCodeEditorSettings = () => {
const [isDarkMode, setIsDarkMode] = useState(readTheme);
const [wordWrap, setWordWrap] = useState(readWordWrap); const [wordWrap, setWordWrap] = useState(readWordWrap);
const [minimapEnabled, setMinimapEnabled] = useState(() => ( const [minimapEnabled, setMinimapEnabled] = useState(() => (
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled) readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
@@ -43,18 +33,13 @@ export const useCodeEditorSettings = () => {
)); ));
const [fontSize, setFontSize] = useState(readFontSize); const [fontSize, setFontSize] = useState(readFontSize);
// Keep legacy behavior where the editor writes theme and wrap settings directly. // Keep legacy behavior where the editor writes wrap settings directly.
useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
useEffect(() => { useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap)); localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
}, [wordWrap]); }, [wordWrap]);
useEffect(() => { useEffect(() => {
const refreshFromStorage = () => { const refreshFromStorage = () => {
setIsDarkMode(readTheme());
setWordWrap(readWordWrap()); setWordWrap(readWordWrap());
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)); setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers)); setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
@@ -71,8 +56,6 @@ export const useCodeEditorSettings = () => {
}, []); }, []);
return { return {
isDarkMode,
setIsDarkMode,
wordWrap, wordWrap,
setWordWrap, setWordWrap,
minimapEnabled, minimapEnabled,

View File

@@ -0,0 +1,63 @@
// Some binary files can't be edited as text, but the browser can still render
// them natively (images, PDFs, audio, video). For those we show an inline
// preview instead of the generic "binary file" placeholder. Anything not listed
// here (zip, exe, avi, mkv, fonts, ...) falls through to the binary message.
export type PreviewKind = 'image' | 'pdf' | 'video' | 'audio';
// Single source of truth: every extension the browser can preview, mapped to the
// MIME type we apply when the server response has a missing/generic Content-Type.
// The preview kind is derived from the MIME type so the two never drift apart.
// Formats browsers generally can't play (avi, mkv, flv, wmv) are intentionally
// absent and keep the binary fallback.
const EXTENSION_MIME: Record<string, string> = {
// Images
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
webp: 'image/webp',
ico: 'image/x-icon',
bmp: 'image/bmp',
avif: 'image/avif',
apng: 'image/apng',
// PDF
pdf: 'application/pdf',
// Video
mp4: 'video/mp4',
webm: 'video/webm',
ogv: 'video/ogg',
mov: 'video/quicktime',
m4v: 'video/x-m4v',
// Audio
mp3: 'audio/mpeg',
wav: 'audio/wav',
m4a: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
opus: 'audio/opus',
oga: 'audio/ogg',
ogg: 'audio/ogg',
weba: 'audio/webm',
};
const extensionOf = (filename: string): string => filename.split('.').pop()?.toLowerCase() ?? '';
const kindForMime = (mime: string): PreviewKind | null => {
if (mime === 'application/pdf') return 'pdf';
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
return null;
};
export const getPreviewKind = (filename: string): PreviewKind | null => {
const mime = EXTENSION_MIME[extensionOf(filename)];
return mime ? kindForMime(mime) : null;
};
// MIME type to fall back to when the server returns no/generic Content-Type.
// Returns undefined for non-previewable extensions.
export const getPreviewMimeType = (filename: string): string | undefined =>
EXTENSION_MIME[extensionOf(filename)];

View File

@@ -1,9 +1,11 @@
import { EditorView } from '@codemirror/view'; import { EditorView } from '@codemirror/view';
import { unifiedMergeView } from '@codemirror/merge'; import { unifiedMergeView } from '@codemirror/merge';
import type { Extension } from '@codemirror/state'; import type { Extension } from '@codemirror/state';
import { useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import { useTheme } from '../../../contexts/ThemeContext';
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument'; import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings'; import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts'; import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
@@ -11,11 +13,13 @@ import type { CodeEditorFile } from '../types/types';
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions'; import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
import { getEditorStyles } from '../utils/editorStyles'; import { getEditorStyles } from '../utils/editorStyles';
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel'; import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
import CodeEditorFooter from './subcomponents/CodeEditorFooter'; import CodeEditorFooter from './subcomponents/CodeEditorFooter';
import CodeEditorHeader from './subcomponents/CodeEditorHeader'; import CodeEditorHeader from './subcomponents/CodeEditorHeader';
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState'; import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
import CodeEditorSurface from './subcomponents/CodeEditorSurface'; import CodeEditorSurface from './subcomponents/CodeEditorSurface';
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile'; import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview';
type CodeEditorProps = { type CodeEditorProps = {
file: CodeEditorFile; file: CodeEditorFile;
@@ -42,8 +46,10 @@ export default function CodeEditor({
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo)); const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
const [markdownPreview, setMarkdownPreview] = useState(false); const [markdownPreview, setMarkdownPreview] = useState(false);
// The code editor follows the app-wide theme; it has no theme of its own.
const { isDarkMode } = useTheme();
const { const {
isDarkMode,
wordWrap, wordWrap,
minimapEnabled, minimapEnabled,
showLineNumbers, showLineNumbers,
@@ -58,6 +64,8 @@ export default function CodeEditor({
saveSuccess, saveSuccess,
saveError, saveError,
isBinary, isBinary,
previewKind,
fileProjectId,
handleSave, handleSave,
handleDownload, handleDownload,
} = useCodeEditorDocument({ } = useCodeEditorDocument({
@@ -70,6 +78,29 @@ export default function CodeEditor({
return extension === 'md' || extension === 'markdown'; return extension === 'md' || extension === 'markdown';
}, [file.name]); }, [file.name]);
const isHtmlPreviewFile = useMemo(() => {
const extension = file.name.split('.').pop()?.toLowerCase();
return extension === 'html' || extension === 'htm';
}, [file.name]);
const openHtmlPreview = useCallback(() => {
const previewWindow = window.open('', '_blank');
if (!previewWindow) return;
previewWindow.opener = null;
previewWindow.document.title = file.name;
previewWindow.document.body.style.margin = '0';
const iframe = previewWindow.document.createElement('iframe');
iframe.title = file.name;
iframe.sandbox.add('allow-forms', 'allow-modals', 'allow-popups', 'allow-scripts');
iframe.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;background:white';
iframe.srcdoc = content;
previewWindow.document.body.appendChild(iframe);
}, [content, file.name]);
const minimapExtension = useMemo( const minimapExtension = useMemo(
() => ( () => (
createMinimapExtension({ createMinimapExtension({
@@ -162,6 +193,30 @@ export default function CodeEditor({
); );
} }
// Natively previewable media (image/pdf/audio/video) is rendered inline
// instead of showing the generic "cannot be displayed" placeholder.
if (previewKind) {
return (
<CodeEditorMediaPreview
file={file}
kind={previewKind}
projectId={fileProjectId}
isSidebar={isSidebar}
isFullscreen={isFullscreen}
onClose={onClose}
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
labels={{
loading: t('filePreview.loading', 'Loading preview...'),
error: t('filePreview.error', 'Unable to display this file.'),
openInNewTab: t('filePreview.openInNewTab', 'Open in new tab'),
fullscreen: t('actions.fullscreen', 'Fullscreen'),
exitFullscreen: t('actions.exitFullscreen', 'Exit fullscreen'),
close: t('actions.close', 'Close'),
}}
/>
);
}
// Binary file display // Binary file display
if (isBinary) { if (isBinary) {
return ( return (
@@ -197,10 +252,12 @@ export default function CodeEditor({
isSidebar={isSidebar} isSidebar={isSidebar}
isFullscreen={isFullscreen} isFullscreen={isFullscreen}
isMarkdownFile={isMarkdownFile} isMarkdownFile={isMarkdownFile}
isHtmlPreviewFile={isHtmlPreviewFile}
markdownPreview={markdownPreview} markdownPreview={markdownPreview}
saving={saving} saving={saving}
saveSuccess={saveSuccess} saveSuccess={saveSuccess}
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)} onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
onOpenHtmlPreview={openHtmlPreview}
onOpenSettings={() => paletteOps.openSettings('appearance')} onOpenSettings={() => paletteOps.openSettings('appearance')}
onDownload={handleDownload} onDownload={handleDownload}
onSave={handleSave} onSave={handleSave}
@@ -210,6 +267,7 @@ export default function CodeEditor({
showingChanges: t('header.showingChanges'), showingChanges: t('header.showingChanges'),
editMarkdown: t('actions.editMarkdown'), editMarkdown: t('actions.editMarkdown'),
previewMarkdown: t('actions.previewMarkdown'), previewMarkdown: t('actions.previewMarkdown'),
previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'),
settings: t('toolbar.settings'), settings: t('toolbar.settings'),
download: t('actions.download'), download: t('actions.download'),
save: t('actions.save'), save: t('actions.save'),

View File

@@ -1,4 +1,5 @@
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react'; import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
import type { CodeEditorFile } from '../../types/types'; import type { CodeEditorFile } from '../../types/types';
type CodeEditorHeaderProps = { type CodeEditorHeaderProps = {
@@ -6,10 +7,12 @@ type CodeEditorHeaderProps = {
isSidebar: boolean; isSidebar: boolean;
isFullscreen: boolean; isFullscreen: boolean;
isMarkdownFile: boolean; isMarkdownFile: boolean;
isHtmlPreviewFile: boolean;
markdownPreview: boolean; markdownPreview: boolean;
saving: boolean; saving: boolean;
saveSuccess: boolean; saveSuccess: boolean;
onToggleMarkdownPreview: () => void; onToggleMarkdownPreview: () => void;
onOpenHtmlPreview: () => void;
onOpenSettings: () => void; onOpenSettings: () => void;
onDownload: () => void; onDownload: () => void;
onSave: () => void; onSave: () => void;
@@ -19,6 +22,7 @@ type CodeEditorHeaderProps = {
showingChanges: string; showingChanges: string;
editMarkdown: string; editMarkdown: string;
previewMarkdown: string; previewMarkdown: string;
previewHtml: string;
settings: string; settings: string;
download: string; download: string;
save: string; save: string;
@@ -35,10 +39,12 @@ export default function CodeEditorHeader({
isSidebar, isSidebar,
isFullscreen, isFullscreen,
isMarkdownFile, isMarkdownFile,
isHtmlPreviewFile,
markdownPreview, markdownPreview,
saving, saving,
saveSuccess, saveSuccess,
onToggleMarkdownPreview, onToggleMarkdownPreview,
onOpenHtmlPreview,
onOpenSettings, onOpenSettings,
onDownload, onDownload,
onSave, onSave,
@@ -82,6 +88,17 @@ export default function CodeEditorHeader({
</button> </button>
)} )}
{isHtmlPreviewFile && (
<button
type="button"
onClick={onOpenHtmlPreview}
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
title={labels.previewHtml}
>
<Eye className="h-4 w-4" />
</button>
)}
<button <button
type="button" type="button"
onClick={onOpenSettings} onClick={onOpenSettings}

View File

@@ -0,0 +1,289 @@
import { useEffect, useState } from 'react';
import { authenticatedFetch } from '../../../../utils/api';
import type { CodeEditorFile } from '../../types/types';
import { getPreviewMimeType, type PreviewKind } from '../../utils/previewableFile';
type CodeEditorMediaPreviewProps = {
file: CodeEditorFile;
kind: PreviewKind;
// DB projectId used to build the raw-content URL; falls back to projectPath
// for older callers, mirroring useCodeEditorDocument.
projectId?: string;
isSidebar: boolean;
isFullscreen: boolean;
onClose: () => void;
onToggleFullscreen: () => void;
labels: {
loading: string;
error: string;
openInNewTab: string;
fullscreen: string;
exitFullscreen: string;
close: string;
};
};
// Reject a "PDF" whose bytes aren't actually a PDF before handing it to the
// same-origin iframe, so a mislabeled HTML/SVG file can't run in the app origin.
const PDF_HEADER_SCAN_BYTES = 1024;
const looksLikePdf = async (blob: Blob): Promise<boolean> => {
const header = await blob.slice(0, PDF_HEADER_SCAN_BYTES).arrayBuffer();
// PDFs must contain the "%PDF-" marker at the very start of the file.
return new TextDecoder('latin1').decode(header).includes('%PDF-');
};
export default function CodeEditorMediaPreview({
file,
kind,
projectId,
isSidebar,
isFullscreen,
onClose,
onToggleFullscreen,
labels,
}: CodeEditorMediaPreviewProps) {
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// Identifies which file the current `url` was loaded for. Rendering is gated on
// this so a blob from a previously-opened file can never show under the new
// file (the editor reuses this component instance across files).
const [loadedKey, setLoadedKey] = useState<string | null>(null);
const sourceKey = `${projectId ?? ''}:${file.path}:${kind}`;
useEffect(() => {
if (!projectId) {
setUrl(null);
setLoadedKey(null);
setError(labels.error);
setLoading(false);
return;
}
let objectUrl: string | null = null;
const controller = new AbortController();
const loadMedia = async () => {
try {
setLoading(true);
setError(null);
setUrl(null);
// The content endpoint requires the auth header, so we fetch the bytes
// ourselves and hand the media element a blob URL instead of a bare src.
// Fetching a blob (rather than streaming) also lets <video>/<audio> seek.
const contentUrl = `/api/projects/${projectId}/files/content?path=${encodeURIComponent(file.path)}`;
const response = await authenticatedFetch(contentUrl, { signal: controller.signal });
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const blob = await response.blob();
// Pick the MIME type to expose to the browser. Preserve a valid
// Content-Type from the server, but supply an extension-specific
// default when it is missing or generic (application/octet-stream),
// otherwise formats like webm/ogg/flac/svg won't render.
const fallbackMime = getPreviewMimeType(file.name);
const isGenericType = !blob.type || blob.type === 'application/octet-stream';
const isMislabeledVideo = kind === 'video' && Boolean(fallbackMime) && !blob.type.startsWith('video/');
let outType = isGenericType || isMislabeledVideo ? (fallbackMime ?? blob.type) : blob.type;
if (kind === 'pdf') {
// The PDF renders in a same-origin <iframe>, so verify the bytes are
// really a PDF and pin the type to application/pdf. That forces the
// browser's PDF handler and prevents a mislabeled HTML/SVG file from
// executing scripts in the app's origin.
if (!(await looksLikePdf(blob))) {
throw new Error('File is not a valid PDF');
}
outType = 'application/pdf';
}
const typed = outType && outType !== blob.type ? new Blob([blob], { type: outType }) : blob;
objectUrl = URL.createObjectURL(typed);
// The cleanup may have already run (deps changed during an await), in
// which case it revoked nothing because objectUrl was still null. Don't
// publish a URL the cleanup will never revoke — drop it ourselves.
if (controller.signal.aborted) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
return;
}
setUrl(objectUrl);
setLoadedKey(sourceKey);
} catch (loadError: unknown) {
if (loadError instanceof Error && loadError.name === 'AbortError') {
return;
}
console.error('Error loading preview:', loadError);
setError(labels.error);
} finally {
setLoading(false);
}
};
loadMedia();
return () => {
controller.abort();
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [file.path, file.name, projectId, kind, sourceKey, labels.error]);
// Only expose the blob once it matches the file currently being shown, so a
// stale URL from the previous file is never rendered during a switch.
const currentUrl = url && loadedKey === sourceKey ? url : null;
// SVGs render safely inline via <img> (scripts don't execute there), but the
// open-in-new-tab link is a top-level navigation. A blob URL inherits the
// app's origin, so a user-controlled SVG with an embedded <script> would run
// as same-origin script. Withhold the new-tab action for SVGs.
const isSvg = getPreviewMimeType(file.name) === 'image/svg+xml';
const canOpenInNewTab = Boolean(currentUrl) && !isSvg;
const renderMedia = () => {
if (!currentUrl) return null;
switch (kind) {
case 'image':
return (
<img
src={currentUrl}
alt={file.name}
className="max-h-full max-w-full object-contain"
/>
);
case 'pdf':
// Not sandboxed on purpose: the browser's built-in PDF viewer refuses to
// load inside a sandboxed frame (any `sandbox` value yields a broken
// viewer). Script execution is instead prevented upstream by validating
// the PDF magic bytes and pinning the blob's MIME type to application/pdf.
return <iframe src={currentUrl} title={file.name} className="h-full w-full border-0 bg-white" />;
case 'video':
return (
<video src={currentUrl} controls className="max-h-full max-w-full" autoPlay={false}>
{labels.error}
</video>
);
case 'audio':
return (
<div className="flex w-full max-w-xl flex-col items-center gap-4 px-6">
<p className="max-w-full truncate text-sm text-muted-foreground">{file.name}</p>
<audio src={currentUrl} controls className="w-full">
{labels.error}
</audio>
</div>
);
default:
return null;
}
};
const previewBody = (
<div className="relative flex h-full w-full flex-col items-center justify-center bg-muted/30 p-2">
{loading && (
<div className="text-sm text-muted-foreground">{labels.loading}</div>
)}
{!loading && currentUrl && renderMedia()}
{!loading && !currentUrl && (
<div className="flex flex-col items-center gap-3 p-8 text-center text-muted-foreground">
<p className="text-sm">{error || labels.error}</p>
<p className="break-all text-xs">{file.path}</p>
</div>
)}
</div>
);
const headerActions = (
<div className="flex shrink-0 items-center gap-0.5">
{canOpenInNewTab && currentUrl && (
<a
href={currentUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label={labels.openInNewTab}
title={labels.openInNewTab}
>
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{!isSidebar && (
<button
type="button"
onClick={onToggleFullscreen}
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
>
{isFullscreen ? (
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
</svg>
) : (
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
)}
</button>
)}
<button
type="button"
onClick={onClose}
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label={labels.close}
title={labels.close}
>
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
const header = (
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
</div>
{headerActions}
</div>
);
if (isSidebar) {
return (
<div className="flex h-full w-full flex-col bg-background">
{header}
{previewBody}
</div>
);
}
const containerClassName = isFullscreen
? 'fixed inset-0 z-[9999] bg-background flex flex-col'
: 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4';
const innerClassName = isFullscreen
? 'bg-background flex flex-col w-full h-full'
: 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]';
return (
<div className={containerClassName}>
<div className={innerClassName}>
{header}
{previewBody}
</div>
</div>
);
}

View File

@@ -1,8 +1,9 @@
import { useState } from 'react'; import { useState } from 'react';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { oneDark as prismOneDark, oneLight as prismOneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { copyTextToClipboard } from '../../../../../utils/clipboard'; import { copyTextToClipboard } from '../../../../../utils/clipboard';
import { useTheme } from '../../../../../contexts/ThemeContext';
type MarkdownCodeBlockProps = { type MarkdownCodeBlockProps = {
inline?: boolean; inline?: boolean;
@@ -16,6 +17,7 @@ export default function MarkdownCodeBlock({
node: _node, node: _node,
...props ...props
}: MarkdownCodeBlockProps) { }: MarkdownCodeBlockProps) {
const { isDarkMode } = useTheme();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? ''); const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(rawContent); const looksMultiline = /[\r\n]/.test(rawContent);
@@ -50,20 +52,22 @@ export default function MarkdownCodeBlock({
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} }
})} })}
className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 group-hover:opacity-100" className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 px-2 py-1 text-xs text-foreground/80 opacity-0 transition-opacity hover:bg-muted group-hover:opacity-100"
> >
{copied ? 'Copied!' : 'Copy'} {copied ? 'Copied!' : 'Copy'}
</button> </button>
<SyntaxHighlighter <SyntaxHighlighter
language={language} language={language}
style={prismOneDark} style={isDarkMode ? prismOneDark : prismOneLight}
customStyle={{ customStyle={{
margin: 0, margin: 0,
borderRadius: '0.5rem', borderRadius: '0.75rem',
fontSize: '0.875rem', fontSize: '0.875rem',
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem', padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
...(isDarkMode ? {} : { background: 'hsl(var(--muted))' }),
}} }}
codeTagProps={{ style: isDarkMode ? {} : { background: 'transparent' } }}
> >
{rawContent} {rawContent}
</SyntaxHighlighter> </SyntaxHighlighter>

View File

@@ -12,6 +12,9 @@ type MarkdownPreviewProps = {
const markdownPreviewComponents: Components = { const markdownPreviewComponents: Components = {
code: MarkdownCodeBlock, code: MarkdownCodeBlock,
// MarkdownCodeBlock renders its own highlighted <pre>; passthrough prevents a
// second Typography-styled <pre> shell from framing it.
pre: ({ children }) => <>{children}</>,
blockquote: ({ children }) => ( blockquote: ({ children }) => (
<blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400"> <blockquote className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
{children} {children}

View File

@@ -189,7 +189,7 @@ export default function GitPanelHeader({
<button <button
onClick={requestPublishConfirmation} onClick={requestPublishConfirmation}
disabled={anyPending} disabled={anyPending}
className="flex items-center gap-1 rounded-lg bg-purple-600 px-2.5 py-1 text-sm text-white transition-colors hover:bg-purple-700 disabled:opacity-50" className="flex items-center gap-1 rounded-lg bg-primary px-2.5 py-1 text-sm text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
title={`Publish "${currentBranch}" to ${remoteName}`} title={`Publish "${currentBranch}" to ${remoteName}`}
> >
<Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} /> <Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} />

View File

@@ -54,7 +54,7 @@ function MainContent({
newSessionTrigger, newSessionTrigger,
}: MainContentProps) { }: MainContentProps) {
const { preferences } = useUiPreferences(); const { preferences } = useUiPreferences();
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences; const { showRawParameters, showThinking, sendByCtrlEnter } = preferences;
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue; const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue; const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
@@ -170,10 +170,8 @@ function MainContent({
onNavigateToSession={onNavigateToSession} onNavigateToSession={onNavigateToSession}
onSessionEstablished={onSessionEstablished} onSessionEstablished={onSessionEstablished}
onShowSettings={onShowSettings} onShowSettings={onShowSettings}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters} showRawParameters={showRawParameters}
showThinking={showThinking} showThinking={showThinking}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter} sendByCtrlEnter={sendByCtrlEnter}
externalMessageUpdate={externalMessageUpdate} externalMessageUpdate={externalMessageUpdate}
newSessionTrigger={newSessionTrigger} newSessionTrigger={newSessionTrigger}
@@ -209,7 +207,11 @@ function MainContent({
{shouldShowBrowserTab && activeTab === 'browser' && ( {shouldShowBrowserTab && activeTab === 'browser' && (
<div className="h-full overflow-hidden"> <div className="h-full overflow-hidden">
<BrowserUsePanel isVisible={activeTab === 'browser'} onShowSettings={onShowSettings} /> <BrowserUsePanel
isVisible={activeTab === 'browser'}
projectId={selectedProject.projectId}
onShowSettings={onShowSettings}
/>
</div> </div>
)} )}

View File

@@ -70,10 +70,18 @@ export default function MainContentTitle({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{activeTab === 'chat' && selectedSession ? ( {activeTab === 'chat' && selectedSession ? (
<div className="min-w-0"> <div className="min-w-0">
<h2 className="scrollbar-hide overflow-x-auto whitespace-nowrap text-sm font-semibold leading-tight text-foreground"> <h2 title={getSessionTitle(selectedSession)} className="truncate text-sm font-semibold leading-tight text-foreground">
{getSessionTitle(selectedSession)} {getSessionTitle(selectedSession)}
</h2> </h2>
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div> <div className="flex min-w-0 items-center gap-2 text-[11px] leading-tight text-muted-foreground">
<span className="min-w-0 truncate">{selectedProject.displayName}</span>
<span
className="hidden min-w-0 max-w-[45%] flex-shrink truncate border-l border-border/60 pl-2 font-mono text-[10px] sm:block"
title={selectedSession.id}
>
{selectedSession.id}
</span>
</div>
</div> </div>
) : showChatNewSession ? ( ) : showChatNewSession ? (
<div className="min-w-0"> <div className="min-w-0">

View File

@@ -29,11 +29,11 @@ export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
export const MCP_GLOBAL_SUPPORTED_TRANSPORTS: McpTransport[] = ['stdio', 'http']; export const MCP_GLOBAL_SUPPORTED_TRANSPORTS: McpTransport[] = ['stdio', 'http'];
export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = { export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
claude: 'bg-purple-600 text-white hover:bg-purple-700', claude: 'bg-primary text-primary-foreground hover:bg-primary/90',
cursor: 'bg-purple-600 text-white hover:bg-purple-700', cursor: 'bg-primary text-primary-foreground hover:bg-primary/90',
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600', codex: 'bg-primary text-primary-foreground hover:bg-primary/90',
gemini: 'bg-blue-600 text-white hover:bg-blue-700', gemini: 'bg-primary text-primary-foreground hover:bg-primary/90',
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600', opencode: 'bg-primary text-primary-foreground hover:bg-primary/90',
}; };
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = { export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {

View File

@@ -135,7 +135,7 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Server className="h-5 w-5 text-purple-500" /> <Server className="h-5 w-5 text-primary" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3> <h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div> </div>
<p className="text-sm text-muted-foreground">{description}</p> <p className="text-sm text-muted-foreground">{description}</p>

View File

@@ -1,4 +1,5 @@
import { FolderOpen, Globe, X } from 'lucide-react'; import { FolderOpen, Globe, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../../shared/view/ui'; import { Button, Input } from '../../../../shared/view/ui';
@@ -119,8 +120,8 @@ export default function McpServerFormModal({
const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider]; const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider];
const showCodexOnlyFields = provider === 'codex' && !isGlobalMode; const showCodexOnlyFields = provider === 'codex' && !isGlobalMode;
return ( return createPortal(
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4"> <div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4">
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background"> <div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background">
<div className="flex items-center justify-between border-b border-border p-4"> <div className="flex items-center justify-between border-b border-border p-4">
<h3 className="text-lg font-medium text-foreground">{modalTitle}</h3> <h3 className="text-lg font-medium text-foreground">{modalTitle}</h3>
@@ -418,7 +419,7 @@ export default function McpServerFormModal({
<Button <Button
type="submit" type="submit"
disabled={isSubmitting || !canSubmit} disabled={isSubmitting || !canSubmit}
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50" className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{isSubmitting {isSubmitting
? t('mcpForm.actions.saving') ? t('mcpForm.actions.saving')
@@ -429,6 +430,7 @@ export default function McpServerFormModal({
</div> </div>
</form> </form>
</div> </div>
</div> </div>,
document.body,
); );
} }

View File

@@ -148,11 +148,18 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
return ( return (
<> <>
<div className="flex min-h-screen items-center justify-center bg-background p-4"> <div className="relative h-screen overflow-y-auto bg-background">
<div className="w-full max-w-2xl"> <div aria-hidden className="pointer-events-none fixed inset-0">
<div className="absolute -top-40 left-1/2 h-[36rem] w-[36rem] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl" />
<div className="absolute -bottom-32 -left-24 h-[26rem] w-[26rem] rounded-full bg-primary/5 blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(hsl(var(--foreground)/0.04)_1px,transparent_1px)] [background-size:22px_22px] opacity-60" />
</div>
<div className="relative mx-auto flex min-h-full w-full max-w-2xl items-center justify-center p-4">
<div className="w-full py-6">
<OnboardingStepProgress currentStep={currentStep} /> <OnboardingStepProgress currentStep={currentStep} />
<div className="rounded-lg border border-border bg-card p-8 shadow-lg"> <div className="rounded-2xl border border-border/70 bg-card/90 p-6 shadow-[0_24px_60px_-20px_hsl(var(--foreground)/0.18)] ring-1 ring-foreground/5 backdrop-blur-xl">
{currentStep === 0 ? ( {currentStep === 0 ? (
<GitConfigurationStep <GitConfigurationStep
gitName={gitName} gitName={gitName}
@@ -168,13 +175,16 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
/> />
)} )}
{errorMessage && ( {errorMessage && (
<div className="mt-6 rounded-lg border border-red-300 bg-red-100 p-4 dark:border-red-800 dark:bg-red-900/20"> <div
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p> role="alert"
</div> className="mt-5 rounded-xl border border-destructive/30 bg-destructive/10 p-3.5"
)} >
<p className="text-sm text-destructive">{errorMessage}</p>
</div>
)}
<div className="mt-8 flex items-center justify-between border-t border-border pt-6"> <div className="mt-6 flex items-center justify-between border-t border-border pt-5">
<button <button
onClick={handlePreviousStep} onClick={handlePreviousStep}
disabled={currentStep === 0 || isSubmitting} disabled={currentStep === 0 || isSubmitting}
@@ -189,7 +199,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
<button <button
onClick={handleNextStep} onClick={handleNextStep}
disabled={!isCurrentStepValid || isSubmitting} disabled={!isCurrentStepValid || isSubmitting}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400" className="flex items-center gap-2 rounded-xl bg-primary px-6 py-2.5 font-medium text-primary-foreground shadow-lg shadow-primary/25 transition-all duration-200 hover:brightness-110 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
@@ -207,7 +217,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
<button <button
onClick={handleFinish} onClick={handleFinish}
disabled={isSubmitting} disabled={isSubmitting}
className="flex items-center gap-2 rounded-lg bg-green-600 px-6 py-3 font-medium text-white transition-colors duration-200 hover:bg-green-700 disabled:cursor-not-allowed disabled:bg-green-400" className="flex items-center gap-2 rounded-xl bg-emerald-600 px-6 py-2.5 font-medium text-white shadow-lg shadow-emerald-600/25 transition-all duration-200 hover:bg-emerald-700 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
@@ -225,6 +235,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
</div> </div>

View File

@@ -31,26 +31,26 @@ export default function AgentConnectionCard({
: status.error || 'Not connected'; : status.error || 'Not connected';
return ( return (
<div className={`rounded-lg border p-4 transition-colors ${containerClassName}`}> <div className={`rounded-xl border px-3 py-2.5 transition-colors ${containerClassName}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${iconContainerClassName}`}> <div className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full ${iconContainerClassName}`}>
<SessionProviderLogo provider={provider} className="h-5 w-5" /> <SessionProviderLogo provider={provider} className="h-5 w-5" />
</div> </div>
<div> <div className="min-w-0">
<div className="flex items-center gap-2 font-medium text-foreground"> <div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{title} {title}
{status.authenticated && <Check className="h-4 w-4 text-green-500" />} {status.authenticated && <Check className="h-3.5 w-3.5 flex-shrink-0 text-emerald-500" />}
</div> </div>
<div className="text-xs text-muted-foreground">{statusText}</div> <div className="truncate text-xs text-muted-foreground" title={statusText}>{statusText}</div>
</div> </div>
</div> </div>
{!status.authenticated && !status.loading && ( {!status.authenticated && !status.loading && (
<button <button
onClick={onLogin} onClick={onLogin}
className={`${loginButtonClassName} rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors`} className={`${loginButtonClassName} flex-shrink-0 rounded-lg px-4 py-1.5 text-sm font-medium text-white transition-colors`}
> >
Login Login
</button> </button>

View File

@@ -51,15 +51,15 @@ export default function AgentConnectionsStep({
onOpenProviderLogin, onOpenProviderLogin,
}: AgentConnectionsStepProps) { }: AgentConnectionsStepProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<div className="mb-6 text-center"> <div className="text-center">
<h2 className="mb-2 text-2xl font-bold text-foreground">Connect Your AI Agents</h2> <h2 className="font-serif text-xl font-bold tracking-tight text-foreground">Connect Your AI Agents</h2>
<p className="text-muted-foreground"> <p className="mx-auto mt-1 max-w-sm text-sm leading-relaxed text-muted-foreground">
Login to one or more AI coding assistants. All are optional. Login to one or more AI coding assistants. All are optional.
</p> </p>
</div> </div>
<div className="space-y-3"> <div className="-mr-1 max-h-[38vh] space-y-2 overflow-y-auto pr-1">
{providerCards.map((providerCard) => ( {providerCards.map((providerCard) => (
<AgentConnectionCard <AgentConnectionCard
key={providerCard.provider} key={providerCard.provider}
@@ -74,9 +74,7 @@ export default function AgentConnectionsStep({
))} ))}
</div> </div>
<div className="pt-2 text-center text-sm text-muted-foreground"> <p className="text-center text-xs text-muted-foreground">You can configure these later in Settings.</p>
<p>You can configure these later in Settings.</p>
</div>
</div> </div>
); );
} }

View File

@@ -16,13 +16,13 @@ export default function GitConfigurationStep({
onGitEmailChange, onGitEmailChange,
}: GitConfigurationStepProps) { }: GitConfigurationStepProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-5">
<div className="mb-8 text-center"> <div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30"> <div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 ring-1 ring-inset ring-primary/20">
<GitBranch className="h-8 w-8 text-blue-600 dark:text-blue-400" /> <GitBranch className="h-7 w-7 text-primary" />
</div> </div>
<h2 className="mb-2 text-2xl font-bold text-foreground">Git Configuration</h2> <h2 className="font-serif text-xl font-bold tracking-tight text-foreground">Git Configuration</h2>
<p className="text-muted-foreground"> <p className="mx-auto mt-1 max-w-sm text-sm leading-relaxed text-muted-foreground">
Configure your git identity to ensure proper attribution for commits. Configure your git identity to ensure proper attribution for commits.
</p> </p>
</div> </div>
@@ -38,7 +38,7 @@ export default function GitConfigurationStep({
id="gitName" id="gitName"
value={gitName} value={gitName}
onChange={(event) => onGitNameChange(event.target.value)} onChange={(event) => onGitNameChange(event.target.value)}
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full rounded-xl border border-border bg-background/60 px-4 py-2.5 text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/60 hover:border-foreground/20 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="John Doe" placeholder="John Doe"
required required
disabled={isSubmitting} disabled={isSubmitting}
@@ -56,7 +56,7 @@ export default function GitConfigurationStep({
id="gitEmail" id="gitEmail"
value={gitEmail} value={gitEmail}
onChange={(event) => onGitEmailChange(event.target.value)} onChange={(event) => onGitEmailChange(event.target.value)}
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full rounded-xl border border-border bg-background/60 px-4 py-2.5 text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/60 hover:border-foreground/20 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="john@example.com" placeholder="john@example.com"
required required
disabled={isSubmitting} disabled={isSubmitting}

View File

@@ -11,7 +11,7 @@ const onboardingSteps = [
export default function OnboardingStepProgress({ currentStep }: OnboardingStepProgressProps) { export default function OnboardingStepProgress({ currentStep }: OnboardingStepProgressProps) {
return ( return (
<div className="mb-8"> <div className="mb-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{onboardingSteps.map((step, index) => { {onboardingSteps.map((step, index) => {
const isCompleted = index < currentStep; const isCompleted = index < currentStep;
@@ -22,18 +22,18 @@ export default function OnboardingStepProgress({ currentStep }: OnboardingStepPr
<div key={step.title} className="contents"> <div key={step.title} className="contents">
<div className="flex flex-1 flex-col items-center"> <div className="flex flex-1 flex-col items-center">
<div <div
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 transition-colors duration-200 ${ className={`flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all duration-200 ${
isCompleted isCompleted
? 'border-green-500 bg-green-500 text-white' ? 'border-emerald-500 bg-emerald-500 text-white shadow-lg shadow-emerald-500/25'
: isActive : isActive
? 'border-blue-600 bg-blue-600 text-white' ? 'border-primary bg-primary text-primary-foreground shadow-lg shadow-primary/25'
: 'border-border bg-background text-muted-foreground' : 'border-border bg-card text-muted-foreground'
}`} }`}
> >
{isCompleted ? <Check className="h-6 w-6" /> : <Icon className="h-6 w-6" />} {isCompleted ? <Check className="h-5 w-5" /> : <Icon className="h-5 w-5" />}
</div> </div>
<div className="mt-2 text-center"> <div className="mt-1.5 text-center">
<p className={`text-sm font-medium ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}> <p className={`text-sm font-medium ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}>
{step.title} {step.title}
</p> </p>
@@ -42,7 +42,7 @@ export default function OnboardingStepProgress({ currentStep }: OnboardingStepPr
</div> </div>
{index < onboardingSteps.length - 1 && ( {index < onboardingSteps.length - 1 && (
<div className={`mx-2 h-0.5 flex-1 transition-colors duration-200 ${isCompleted ? 'bg-green-500' : 'bg-border'}`} /> <div className={`mx-2 h-0.5 flex-1 transition-colors duration-200 ${isCompleted ? 'bg-emerald-500' : 'bg-border'}`} />
)} )}
</div> </div>
); );

View File

@@ -1,11 +1,10 @@
import { import {
ArrowDown,
Brain, Brain,
Eye, Eye,
Languages, Languages,
Maximize2,
Mic, Mic,
} from 'lucide-react'; } from 'lucide-react';
import type { PreferenceToggleItem } from './types'; import type { PreferenceToggleItem } from './types';
export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition'; export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition';
@@ -16,7 +15,7 @@ export const HANDLE_POSITION_MAX = 90;
export const DRAG_THRESHOLD_PX = 5; export const DRAG_THRESHOLD_PX = 5;
export const SETTING_ROW_CLASS = export const SETTING_ROW_CLASS =
'flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600'; 'flex items-center justify-between p-3 rounded-lg bg-muted/60 hover:bg-accent transition-colors border border-transparent hover:border-border';
export const TOGGLE_ROW_CLASS = `${SETTING_ROW_CLASS} cursor-pointer`; export const TOGGLE_ROW_CLASS = `${SETTING_ROW_CLASS} cursor-pointer`;
@@ -24,11 +23,6 @@ export const CHECKBOX_CLASS =
'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600'; 'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600';
export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [ export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [
{
key: 'autoExpandTools',
labelKey: 'quickSettings.autoExpandTools',
icon: Maximize2,
},
{ {
key: 'showRawParameters', key: 'showRawParameters',
labelKey: 'quickSettings.showRawParameters', labelKey: 'quickSettings.showRawParameters',
@@ -41,14 +35,6 @@ export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [
}, },
]; ];
export const VIEW_OPTION_TOGGLES: PreferenceToggleItem[] = [
{
key: 'autoScrollToBottom',
labelKey: 'quickSettings.autoScrollToBottom',
icon: ArrowDown,
},
];
export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [ export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [
{ {
key: 'sendByCtrlEnter', key: 'sendByCtrlEnter',

View File

@@ -2,10 +2,8 @@ import type { CSSProperties } from 'react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
export type PreferenceToggleKey = export type PreferenceToggleKey =
| 'autoExpandTools'
| 'showRawParameters' | 'showRawParameters'
| 'showThinking' | 'showThinking'
| 'autoScrollToBottom'
| 'sendByCtrlEnter' | 'sendByCtrlEnter'
| 'voiceEnabled'; | 'voiceEnabled';

View File

@@ -1,18 +1,19 @@
import { Moon, Sun } from 'lucide-react'; import { Moon, Sun } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { DarkModeToggle } from '../../../shared/view/ui'; import { DarkModeToggle } from '../../../shared/view/ui';
import LanguageSelector from '../../../shared/view/ui/LanguageSelector'; import LanguageSelector from '../../../shared/view/ui/LanguageSelector';
import { import {
INPUT_SETTING_TOGGLES, INPUT_SETTING_TOGGLES,
SETTING_ROW_CLASS, SETTING_ROW_CLASS,
TOOL_DISPLAY_TOGGLES, TOOL_DISPLAY_TOGGLES,
VIEW_OPTION_TOGGLES,
} from '../constants'; } from '../constants';
import type { import type {
PreferenceToggleItem, PreferenceToggleItem,
PreferenceToggleKey, PreferenceToggleKey,
QuickSettingsPreferences, QuickSettingsPreferences,
} from '../types'; } from '../types';
import QuickSettingsSection from './QuickSettingsSection'; import QuickSettingsSection from './QuickSettingsSection';
import QuickSettingsToggleRow from './QuickSettingsToggleRow'; import QuickSettingsToggleRow from './QuickSettingsToggleRow';
@@ -48,11 +49,11 @@ export default function QuickSettingsContent({
<div className="flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4"> <div className="flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4">
<QuickSettingsSection title={t('quickSettings.sections.appearance')}> <QuickSettingsSection title={t('quickSettings.sections.appearance')}>
<div className={SETTING_ROW_CLASS}> <div className={SETTING_ROW_CLASS}>
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white"> <span className="flex items-center gap-2 text-sm text-foreground">
{isDarkMode ? ( {isDarkMode ? (
<Moon className="h-4 w-4 text-gray-600 dark:text-gray-400" /> <Moon className="h-4 w-4 text-muted-foreground" />
) : ( ) : (
<Sun className="h-4 w-4 text-gray-600 dark:text-gray-400" /> <Sun className="h-4 w-4 text-muted-foreground" />
)} )}
{t('quickSettings.darkMode')} {t('quickSettings.darkMode')}
</span> </span>
@@ -65,13 +66,9 @@ export default function QuickSettingsContent({
{renderToggleRows(TOOL_DISPLAY_TOGGLES)} {renderToggleRows(TOOL_DISPLAY_TOGGLES)}
</QuickSettingsSection> </QuickSettingsSection>
<QuickSettingsSection title={t('quickSettings.sections.viewOptions')}>
{renderToggleRows(VIEW_OPTION_TOGGLES)}
</QuickSettingsSection>
<QuickSettingsSection title={t('quickSettings.sections.inputSettings')}> <QuickSettingsSection title={t('quickSettings.sections.inputSettings')}>
{renderToggleRows(inputSettingToggles)} {renderToggleRows(inputSettingToggles)}
<p className="ml-3 text-xs text-gray-500 dark:text-gray-400"> <p className="ml-3 text-xs text-muted-foreground">
{t('quickSettings.sendByCtrlEnterDescription')} {t('quickSettings.sendByCtrlEnterDescription')}
</p> </p>
</QuickSettingsSection> </QuickSettingsSection>

View File

@@ -5,9 +5,9 @@ export default function QuickSettingsPanelHeader() {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
return ( return (
<div className="border-b border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900"> <div className="border-b border-border bg-muted/40 p-4">
<h3 className="flex items-center gap-2 text-lg font-semibold text-gray-900 dark:text-white"> <h3 className="flex items-center gap-2 text-lg font-semibold text-foreground">
<Settings2 className="h-5 w-5 text-gray-600 dark:text-gray-400" /> <Settings2 className="h-5 w-5 text-muted-foreground" />
{t('quickSettings.title')} {t('quickSettings.title')}
</h3> </h3>
</div> </div>

View File

@@ -1,10 +1,12 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import type { MouseEvent as ReactMouseEvent } from 'react'; import type { MouseEvent as ReactMouseEvent } from 'react';
import { useDeviceSettings } from '../../../hooks/useDeviceSettings'; import { useDeviceSettings } from '../../../hooks/useDeviceSettings';
import { useUiPreferences } from '../../../hooks/useUiPreferences'; import { useUiPreferences } from '../../../hooks/useUiPreferences';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { useQuickSettingsDrag } from '../hooks/useQuickSettingsDrag'; import { useQuickSettingsDrag } from '../hooks/useQuickSettingsDrag';
import type { PreferenceToggleKey, QuickSettingsPreferences } from '../types'; import type { PreferenceToggleKey, QuickSettingsPreferences } from '../types';
import QuickSettingsContent from './QuickSettingsContent'; import QuickSettingsContent from './QuickSettingsContent';
import QuickSettingsHandle from './QuickSettingsHandle'; import QuickSettingsHandle from './QuickSettingsHandle';
import QuickSettingsPanelHeader from './QuickSettingsPanelHeader'; import QuickSettingsPanelHeader from './QuickSettingsPanelHeader';
@@ -22,15 +24,11 @@ export default function QuickSettingsPanelView() {
} = useQuickSettingsDrag({ isMobile }); } = useQuickSettingsDrag({ isMobile });
const quickSettingsPreferences = useMemo<QuickSettingsPreferences>(() => ({ const quickSettingsPreferences = useMemo<QuickSettingsPreferences>(() => ({
autoExpandTools: preferences.autoExpandTools,
showRawParameters: preferences.showRawParameters, showRawParameters: preferences.showRawParameters,
showThinking: preferences.showThinking, showThinking: preferences.showThinking,
autoScrollToBottom: preferences.autoScrollToBottom,
sendByCtrlEnter: preferences.sendByCtrlEnter, sendByCtrlEnter: preferences.sendByCtrlEnter,
voiceEnabled: preferences.voiceEnabled, voiceEnabled: preferences.voiceEnabled,
}), [ }), [
preferences.autoExpandTools,
preferences.autoScrollToBottom,
preferences.sendByCtrlEnter, preferences.sendByCtrlEnter,
preferences.showRawParameters, preferences.showRawParameters,
preferences.showThinking, preferences.showThinking,

View File

@@ -13,7 +13,7 @@ export default function QuickSettingsSection({
}: QuickSettingsSectionProps) { }: QuickSettingsSectionProps) {
return ( return (
<div className={`space-y-2 ${className}`}> <div className={`space-y-2 ${className}`}>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"> <h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title} {title}
</h4> </h4>
{children} {children}

View File

@@ -17,8 +17,8 @@ function QuickSettingsToggleRow({
}: QuickSettingsToggleRowProps) { }: QuickSettingsToggleRowProps) {
return ( return (
<label className={TOGGLE_ROW_CLASS}> <label className={TOGGLE_ROW_CLASS}>
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white"> <span className="flex items-center gap-2 text-sm text-foreground">
<Icon className="h-4 w-4 text-gray-600 dark:text-gray-400" /> <Icon className="h-4 w-4 text-muted-foreground" />
{label} {label}
</span> </span>
<input <input

View File

@@ -45,7 +45,6 @@ export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name'; export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
export const DEFAULT_SAVE_STATUS = null; export const DEFAULT_SAVE_STATUS = null;
export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = { export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
theme: 'dark',
wordWrap: false, wordWrap: false,
showMinimap: true, showMinimap: true,
lineNumbers: true, lineNumbers: true,

View File

@@ -86,7 +86,6 @@ const toCodexPermissionMode = (value: unknown): CodexPermissionMode => {
}; };
const readCodeEditorSettings = (): CodeEditorSettingsState => ({ const readCodeEditorSettings = (): CodeEditorSettingsState => ({
theme: localStorage.getItem('codeEditorTheme') === 'light' ? 'light' : 'dark',
wordWrap: localStorage.getItem('codeEditorWordWrap') === 'true', wordWrap: localStorage.getItem('codeEditorWordWrap') === 'true',
showMinimap: localStorage.getItem('codeEditorShowMinimap') !== 'false', showMinimap: localStorage.getItem('codeEditorShowMinimap') !== 'false',
lineNumbers: localStorage.getItem('codeEditorLineNumbers') !== 'false', lineNumbers: localStorage.getItem('codeEditorLineNumbers') !== 'false',
@@ -330,7 +329,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
}, [notificationPreferences.channels.sound]); }, [notificationPreferences.channels.sound]);
useEffect(() => { useEffect(() => {
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap)); localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
localStorage.setItem('codeEditorShowMinimap', String(codeEditorSettings.showMinimap)); localStorage.setItem('codeEditorShowMinimap', String(codeEditorSettings.showMinimap));
localStorage.setItem('codeEditorLineNumbers', String(codeEditorSettings.lineNumbers)); localStorage.setItem('codeEditorLineNumbers', String(codeEditorSettings.lineNumbers));

View File

@@ -47,7 +47,6 @@ export type CursorPermissionsState = {
}; };
export type CodeEditorSettingsState = { export type CodeEditorSettingsState = {
theme: 'dark' | 'light';
wordWrap: boolean; wordWrap: boolean;
showMinimap: boolean; showMinimap: boolean;
lineNumbers: boolean; lineNumbers: boolean;

View File

@@ -168,7 +168,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
projectSortOrder={projectSortOrder} projectSortOrder={projectSortOrder}
onProjectSortOrderChange={setProjectSortOrder} onProjectSortOrderChange={setProjectSortOrder}
codeEditorSettings={codeEditorSettings} codeEditorSettings={codeEditorSettings}
onCodeEditorThemeChange={(value) => updateCodeEditorSetting('theme', value)}
onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)} onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)}
onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)} onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)}
onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)} onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)}

View File

@@ -1,9 +1,10 @@
import { ExternalLink, MessageSquare, Star } from 'lucide-react'; import { Cloud, ExternalLink, MessageSquare, Star, Users } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CLOUDCLI_WORDMARK_FONT_FAMILY } from '../../../../constants/branding';
import { IS_PLATFORM } from '../../../../constants/config'; import { IS_PLATFORM } from '../../../../constants/config';
import { useVersionCheck } from '../../../../hooks/useVersionCheck'; import { useVersionCheck } from '../../../../hooks/useVersionCheck';
import PremiumFeatureCard from '../PremiumFeatureCard'; import PremiumFeatureCard from '../PremiumFeatureCard';
import { Cloud, Users } from 'lucide-react';
const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui'; const GITHUB_REPO_URL = 'https://github.com/siteboon/claudecodeui';
const DISCORD_URL = 'https://discord.gg/buxwujPNRE'; const DISCORD_URL = 'https://discord.gg/buxwujPNRE';
@@ -40,7 +41,12 @@ export default function AboutTab() {
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-base font-semibold text-foreground">CloudCLI</span> <span
className="text-base font-semibold text-foreground"
style={{ fontFamily: CLOUDCLI_WORDMARK_FONT_FAMILY }}
>
CloudCLI
</span>
<a <a
href={releasesUrl} href={releasesUrl}
target="_blank" target="_blank"

View File

@@ -11,7 +11,6 @@ type AppearanceSettingsTabProps = {
projectSortOrder: ProjectSortOrder; projectSortOrder: ProjectSortOrder;
onProjectSortOrderChange: (value: ProjectSortOrder) => void; onProjectSortOrderChange: (value: ProjectSortOrder) => void;
codeEditorSettings: CodeEditorSettingsState; codeEditorSettings: CodeEditorSettingsState;
onCodeEditorThemeChange: (value: 'dark' | 'light') => void;
onCodeEditorWordWrapChange: (value: boolean) => void; onCodeEditorWordWrapChange: (value: boolean) => void;
onCodeEditorShowMinimapChange: (value: boolean) => void; onCodeEditorShowMinimapChange: (value: boolean) => void;
onCodeEditorLineNumbersChange: (value: boolean) => void; onCodeEditorLineNumbersChange: (value: boolean) => void;
@@ -22,7 +21,6 @@ export default function AppearanceSettingsTab({
projectSortOrder, projectSortOrder,
onProjectSortOrderChange, onProjectSortOrderChange,
codeEditorSettings, codeEditorSettings,
onCodeEditorThemeChange,
onCodeEditorWordWrapChange, onCodeEditorWordWrapChange,
onCodeEditorShowMinimapChange, onCodeEditorShowMinimapChange,
onCodeEditorLineNumbersChange, onCodeEditorLineNumbersChange,
@@ -69,17 +67,6 @@ export default function AppearanceSettingsTab({
<SettingsSection title={t('appearanceSettings.codeEditor.title')}> <SettingsSection title={t('appearanceSettings.codeEditor.title')}>
<SettingsCard divided> <SettingsCard divided>
<SettingsRow
label={t('appearanceSettings.codeEditor.theme.label')}
description={t('appearanceSettings.codeEditor.theme.description')}
>
<DarkModeToggle
checked={codeEditorSettings.theme === 'dark'}
onToggle={(enabled) => onCodeEditorThemeChange(enabled ? 'dark' : 'light')}
ariaLabel={t('appearanceSettings.codeEditor.theme.label')}
/>
</SettingsRow>
<SettingsRow <SettingsRow
label={t('appearanceSettings.codeEditor.wordWrap.label')} label={t('appearanceSettings.codeEditor.wordWrap.label')}
description={t('appearanceSettings.codeEditor.wordWrap.description')} description={t('appearanceSettings.codeEditor.wordWrap.description')}

View File

@@ -1,5 +1,7 @@
import { ExternalLink, Star, MessageSquare } from 'lucide-react'; import { ExternalLink, Star, MessageSquare } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CLOUDCLI_WORDMARK_FONT_FAMILY } from '../../../../../../constants/branding';
import { IS_PLATFORM } from '../../../../../../constants/config'; import { IS_PLATFORM } from '../../../../../../constants/config';
import type { ReleaseInfo } from '../../../../../../types/sharedTypes'; import type { ReleaseInfo } from '../../../../../../types/sharedTypes';
@@ -51,7 +53,12 @@ export default function VersionInfoSection({
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">CloudCLI</span> <span
className="text-sm font-semibold text-foreground"
style={{ fontFamily: CLOUDCLI_WORDMARK_FONT_FAMILY }}
>
CloudCLI
</span>
<a <a
href={releasesUrl} href={releasesUrl}
target="_blank" target="_blank"

View File

@@ -1,22 +1,32 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Download, Loader2 } from 'lucide-react'; import { Download, ExternalLink, Eye, Loader2, Zap } from 'lucide-react';
import { Button } from '../../../../../shared/view/ui'; import { Button, Input } from '../../../../../shared/view/ui';
import { authenticatedFetch } from '../../../../../utils/api'; import { authenticatedFetch } from '../../../../../utils/api';
import SettingsCard from '../../SettingsCard'; import SettingsCard from '../../SettingsCard';
import SettingsRow from '../../SettingsRow'; import SettingsRow from '../../SettingsRow';
import SettingsSection from '../../SettingsSection'; import SettingsSection from '../../SettingsSection';
import SettingsToggle from '../../SettingsToggle'; import SettingsToggle from '../../SettingsToggle';
const BROWSER_USE_GUIDE_URL = 'https://cloudcli.ai/docs/browser-use';
type BrowserUseSettings = { type BrowserUseSettings = {
enabled: boolean; enabled: boolean;
persistSessions: boolean;
defaultProfileName: string;
browserBackend: 'playwright' | 'camoufox-vnc';
}; };
type BrowserUseStatus = { type BrowserUseStatus = {
enabled: boolean; enabled: boolean;
available: boolean; available: boolean;
backend: 'playwright' | 'camoufox-vnc';
browserBackend: 'playwright' | 'camoufox-vnc';
playwrightInstalled: boolean; playwrightInstalled: boolean;
chromiumInstalled: boolean; chromiumInstalled: boolean;
camoufoxInstalled: boolean;
noVncInstalled: boolean;
x11vncInstalled: boolean;
installInProgress: boolean; installInProgress: boolean;
message: string; message: string;
}; };
@@ -32,16 +42,20 @@ async function readJson<T>(response: Response): Promise<T> {
export default function BrowserUseSettingsTab() { export default function BrowserUseSettingsTab() {
const [settings, setSettings] = useState<BrowserUseSettings | null>(null); const [settings, setSettings] = useState<BrowserUseSettings | null>(null);
const [status, setStatus] = useState<BrowserUseStatus | null>(null); const [status, setStatus] = useState<BrowserUseStatus | null>(null);
const [hasLoadedSettings, setHasLoadedSettings] = useState(false);
const [isSettingsLoading, setIsSettingsLoading] = useState(true); const [isSettingsLoading, setIsSettingsLoading] = useState(true);
const [isStatusLoading, setIsStatusLoading] = useState(true); const [isStatusLoading, setIsStatusLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isInstalling, setIsInstalling] = useState(false); const [isInstalling, setIsInstalling] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [profileNameDraft, setProfileNameDraft] = useState('default');
const loadSettings = useCallback(async () => { const loadSettings = useCallback(async () => {
const settingsResponse = await authenticatedFetch('/api/browser-use/settings'); const settingsResponse = await authenticatedFetch('/api/browser-use/settings');
const settingsData = await readJson<{ data: { settings: BrowserUseSettings } }>(settingsResponse); const settingsData = await readJson<{ data: { settings: BrowserUseSettings } }>(settingsResponse);
setSettings(settingsData.data.settings); setSettings(settingsData.data.settings);
setHasLoadedSettings(true);
setProfileNameDraft(settingsData.data.settings.defaultProfileName || 'default');
}, []); }, []);
const loadStatus = useCallback(async () => { const loadStatus = useCallback(async () => {
@@ -52,6 +66,7 @@ export default function BrowserUseSettingsTab() {
useEffect(() => { useEffect(() => {
setError(null); setError(null);
setHasLoadedSettings(false);
setIsSettingsLoading(true); setIsSettingsLoading(true);
setIsStatusLoading(true); setIsStatusLoading(true);
@@ -74,6 +89,7 @@ export default function BrowserUseSettingsTab() {
}); });
const data = await readJson<{ data: { settings: BrowserUseSettings } }>(response); const data = await readJson<{ data: { settings: BrowserUseSettings } }>(response);
setSettings(data.data.settings); setSettings(data.data.settings);
setHasLoadedSettings(true);
window.dispatchEvent(new Event('browserUseSettingsChanged')); window.dispatchEvent(new Event('browserUseSettingsChanged'));
setIsStatusLoading(true); setIsStatusLoading(true);
await loadStatus(); await loadStatus();
@@ -101,8 +117,21 @@ export default function BrowserUseSettingsTab() {
} }
}; };
const saveProfileName = async () => {
const nextName = profileNameDraft.trim() || 'default';
setProfileNameDraft(nextName);
if (nextName === settings?.defaultProfileName) {
return;
}
await updateSettings({ defaultProfileName: nextName });
};
const browserEnabled = settings?.enabled === true; const browserEnabled = settings?.enabled === true;
const needsBrowserBinaries = Boolean(browserEnabled && status && (!status.playwrightInstalled || !status.chromiumInstalled)); const browserDisabled = hasLoadedSettings && settings?.enabled === false;
const persistSessions = settings?.persistSessions === true;
const selectedBackend = settings?.browserBackend || 'playwright';
const effectiveBackend = status?.backend || 'playwright';
const needsBrowserBinaries = Boolean(browserEnabled && status && !status.available);
const runtimeLabel = (installed?: boolean) => { const runtimeLabel = (installed?: boolean) => {
if (isStatusLoading && !status) { if (isStatusLoading && !status) {
return 'checking...'; return 'checking...';
@@ -114,33 +143,165 @@ export default function BrowserUseSettingsTab() {
<div className="space-y-8"> <div className="space-y-8">
<SettingsSection <SettingsSection
title="Browser" title="Browser"
description="Allow agents to create guarded Playwright browser sessions that you can monitor from the Browser tab." description="Give coding agents a working browser so they can open websites, test flows, capture screenshots, and help debug what users actually see."
> >
<SettingsCard divided> <SettingsCard divided>
<SettingsRow <SettingsRow
label="Enable Browser" label="Give Agents Browser Access"
description="Registers Browser for supported agents. Agents can create browser sessions; you can watch, stop, and delete them." description="Let agents use a browser during coding tasks while you can watch live sessions, open them in a tab, and stop them at any time."
> >
{isSettingsLoading && !settings ? ( {isSettingsLoading && !hasLoadedSettings ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : ( ) : hasLoadedSettings ? (
<SettingsToggle <SettingsToggle
checked={browserEnabled} checked={browserEnabled}
onChange={(value) => void updateSettings({ enabled: value })} onChange={(value) => void updateSettings({ enabled: value })}
ariaLabel="Enable Browser" ariaLabel="Give Agents Browser Access"
disabled={isSaving} disabled={isSaving}
/> />
) : (
<span className="text-sm text-muted-foreground">Unavailable</span>
)} )}
</SettingsRow> </SettingsRow>
{browserDisabled && (
<div className="px-4 py-4">
<a
href={BROWSER_USE_GUIDE_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
Read the Browser guide
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
{error && (
<div className="px-4 py-4">
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
{error}
</div>
</div>
)}
{browserEnabled && (
<>
<div className="space-y-3 px-4 py-4">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Browser Engine</div>
<div className="mt-0.5 text-sm text-muted-foreground">
Pick the kind of browser experience agents should use for new sessions.
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{([
{
value: 'playwright' as const,
label: 'Playwright',
description: 'Best for quick checks, screenshots, and automated page interaction when no manual login is needed.',
icon: Zap,
},
{
value: 'camoufox-vnc' as const,
label: 'Camoufox + noVNC',
description: 'Best when a person may need to log in, approve a step, or watch the browser session live.',
icon: Eye,
},
]).map((option) => {
const Icon = option.icon;
const selected = selectedBackend === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => void updateSettings({ browserBackend: option.value })}
disabled={isSaving || isSettingsLoading}
className={[
'group flex min-h-[88px] items-start gap-3 rounded-lg border px-3 py-3 text-left transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
selected
? 'border-primary bg-primary/5 text-foreground shadow-sm'
: 'border-border bg-background hover:border-foreground/20 hover:bg-muted/40',
(isSaving || isSettingsLoading) ? 'cursor-not-allowed opacity-60' : '',
].join(' ')}
aria-pressed={selected}
>
<span className={[
'mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border',
selected ? 'border-primary/30 bg-primary/10 text-primary' : 'border-border bg-muted/40 text-muted-foreground',
].join(' ')}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{option.label}</span>
<span className="mt-1 block text-xs leading-relaxed text-muted-foreground">{option.description}</span>
</span>
</button>
);
})}
</div>
</div>
<SettingsRow
label="Remember Browser Logins"
description="Keep cookies and site storage in a named profile so agents can reuse signed-in sessions instead of starting from scratch."
>
{isSettingsLoading && !settings ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<SettingsToggle
checked={persistSessions}
onChange={(value) => void updateSettings({ persistSessions: value })}
ariaLabel="Remember Browser Logins"
disabled={isSaving}
/>
)}
</SettingsRow>
{persistSessions && (
<SettingsRow
label="Default Browser Profile"
description="New browser sessions use this profile by default, so saved logins stay tied to a predictable workspace."
>
<Input
value={profileNameDraft}
onChange={(event) => setProfileNameDraft(event.target.value)}
onBlur={() => void saveProfileName()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
disabled={isSaving || isSettingsLoading}
className="w-40"
aria-label="Default Browser Profile"
/>
</SettingsRow>
)}
</>
)}
{browserEnabled && (
<div className="space-y-4 px-4 py-4"> <div className="space-y-4 px-4 py-4">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-md border border-border px-2 py-1">
Backend: {effectiveBackend === 'camoufox-vnc' ? 'Camoufox + noVNC' : 'Playwright'}
</span>
<span className="rounded-md border border-border px-2 py-1"> <span className="rounded-md border border-border px-2 py-1">
Playwright: {runtimeLabel(status?.playwrightInstalled)} Playwright: {runtimeLabel(status?.playwrightInstalled)}
</span> </span>
<span className="rounded-md border border-border px-2 py-1"> <span className="rounded-md border border-border px-2 py-1">
Chromium: {runtimeLabel(status?.chromiumInstalled)} Chromium: {runtimeLabel(status?.chromiumInstalled)}
</span> </span>
<span className="rounded-md border border-border px-2 py-1">
Camoufox: {runtimeLabel(status?.camoufoxInstalled)}
</span>
<span className="rounded-md border border-border px-2 py-1">
noVNC: {runtimeLabel(status?.noVncInstalled)}
</span>
<span className="rounded-md border border-border px-2 py-1"> <span className="rounded-md border border-border px-2 py-1">
Status: {isStatusLoading && !status ? 'checking...' : status?.available ? 'ready' : browserEnabled ? 'setup required' : 'disabled'} Status: {isStatusLoading && !status ? 'checking...' : status?.available ? 'ready' : browserEnabled ? 'setup required' : 'disabled'}
</span> </span>
@@ -172,12 +333,17 @@ export default function BrowserUseSettingsTab() {
</div> </div>
)} )}
{error && ( <a
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200"> href={BROWSER_USE_GUIDE_URL}
{error} target="_blank"
</div> rel="noopener noreferrer"
)} className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
Read the Browser guide
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div> </div>
)}
</SettingsCard> </SettingsCard>
</SettingsSection> </SettingsSection>
</div> </div>

View File

@@ -1,6 +1,5 @@
import type { ITerminalOptions } from '@xterm/xterm'; import type { ITerminalOptions } from '@xterm/xterm';
export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
export const SHELL_RESTART_DELAY_MS = 200; export const SHELL_RESTART_DELAY_MS = 200;
export const TERMINAL_INIT_DELAY_MS = 100; export const TERMINAL_INIT_DELAY_MS = 100;
export const TERMINAL_RESIZE_DELAY_MS = 50; export const TERMINAL_RESIZE_DELAY_MS = 50;

View File

@@ -24,7 +24,6 @@ type UseShellConnectionOptions = {
autoConnect: boolean; autoConnect: boolean;
closeSocket: () => void; closeSocket: () => void;
clearTerminalScreen: () => void; clearTerminalScreen: () => void;
setAuthUrl: (nextAuthUrl: string) => void;
onOutputRef?: MutableRefObject<(() => void) | null>; onOutputRef?: MutableRefObject<(() => void) | null>;
}; };
@@ -49,7 +48,6 @@ export function useShellConnection({
autoConnect, autoConnect,
closeSocket, closeSocket,
clearTerminalScreen, clearTerminalScreen,
setAuthUrl,
onOutputRef, onOutputRef,
}: UseShellConnectionOptions): UseShellConnectionResult { }: UseShellConnectionOptions): UseShellConnectionResult {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
@@ -100,14 +98,8 @@ export function useShellConnection({
return; return;
} }
if (message.type === 'auth_url' || message.type === 'url_open') {
const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
if (nextAuthUrl) {
setAuthUrl(nextAuthUrl);
}
}
}, },
[handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef], [handleProcessCompletion, onOutputRef, terminalRef],
); );
const connectWebSocket = useCallback( const connectWebSocket = useCallback(
@@ -133,7 +125,6 @@ export function useShellConnection({
setIsConnected(true); setIsConnected(true);
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false; connectingRef.current = false;
setAuthUrl('');
window.setTimeout(() => { window.setTimeout(() => {
const currentTerminal = terminalRef.current; const currentTerminal = terminalRef.current;
@@ -196,7 +187,6 @@ export function useShellConnection({
isPlainShellRef, isPlainShellRef,
selectedProjectRef, selectedProjectRef,
selectedSessionRef, selectedSessionRef,
setAuthUrl,
terminalRef, terminalRef,
wsRef, wsRef,
], ],
@@ -225,8 +215,7 @@ export function useShellConnection({
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false; connectingRef.current = false;
forceRestartOnInitRef.current = false; forceRestartOnInitRef.current = false;
setAuthUrl(''); }, [clearTerminalScreen, closeSocket]);
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
useEffect(() => { useEffect(() => {
if ( if (

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import type { FitAddon } from '@xterm/addon-fit'; import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm'; import type { Terminal } from '@xterm/xterm';
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types'; import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { useShellConnection } from './useShellConnection'; import { useShellConnection } from './useShellConnection';
import { useShellTerminal } from './useShellTerminal'; import { useShellTerminal } from './useShellTerminal';
@@ -22,15 +23,11 @@ export function useShellRuntime({
const fitAddonRef = useRef<FitAddon | null>(null); const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const [authUrl, setAuthUrl] = useState('');
const [authUrlVersion, setAuthUrlVersion] = useState(0);
const selectedProjectRef = useRef(selectedProject); const selectedProjectRef = useRef(selectedProject);
const selectedSessionRef = useRef(selectedSession); const selectedSessionRef = useRef(selectedSession);
const initialCommandRef = useRef(initialCommand); const initialCommandRef = useRef(initialCommand);
const isPlainShellRef = useRef(isPlainShell); const isPlainShellRef = useRef(isPlainShell);
const onProcessCompleteRef = useRef(onProcessComplete); const onProcessCompleteRef = useRef(onProcessComplete);
const authUrlRef = useRef('');
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null); const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
// Keep mutable values in refs so websocket handlers always read current data. // Keep mutable values in refs so websocket handlers always read current data.
@@ -42,12 +39,6 @@ export function useShellRuntime({
onProcessCompleteRef.current = onProcessComplete; onProcessCompleteRef.current = onProcessComplete;
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]); }, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
authUrlRef.current = nextAuthUrl;
setAuthUrl(nextAuthUrl);
setAuthUrlVersion((previous) => previous + 1);
}, []);
const closeSocket = useCallback(() => { const closeSocket = useCallback(() => {
const activeSocket = wsRef.current; const activeSocket = wsRef.current;
if (!activeSocket) { if (!activeSocket) {
@@ -64,32 +55,6 @@ export function useShellRuntime({
wsRef.current = null; wsRef.current = null;
}, []); }, []);
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
if (!url) {
return false;
}
const popup = window.open(url, '_blank');
if (popup) {
try {
popup.opener = null;
} catch {
// Ignore cross-origin restrictions when trying to null opener.
}
return true;
}
return false;
}, []);
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
if (!url) {
return false;
}
return copyTextToClipboard(url);
}, []);
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({ const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
terminalContainerRef, terminalContainerRef,
terminalRef, terminalRef,
@@ -98,10 +63,6 @@ export function useShellRuntime({
selectedProject, selectedProject,
minimal, minimal,
isRestarting, isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket, closeSocket,
}); });
@@ -118,7 +79,6 @@ export function useShellRuntime({
autoConnect, autoConnect,
closeSocket, closeSocket,
clearTerminalScreen, clearTerminalScreen,
setAuthUrl: setCurrentAuthUrl,
onOutputRef, onOutputRef,
}); });
@@ -156,11 +116,7 @@ export function useShellRuntime({
isConnected, isConnected,
isInitialized, isInitialized,
isConnecting, isConnecting,
authUrl,
authUrlVersion,
connectToShell, connectToShell,
disconnectFromShell, disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
}; };
} }

View File

@@ -4,15 +4,18 @@ import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebLinksAddon } from '@xterm/addon-web-links';
import { WebglAddon } from '@xterm/addon-webgl'; import { WebglAddon } from '@xterm/addon-webgl';
import { Terminal } from '@xterm/xterm'; import { Terminal } from '@xterm/xterm';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { import {
CODEX_DEVICE_AUTH_URL,
TERMINAL_INIT_DELAY_MS, TERMINAL_INIT_DELAY_MS,
TERMINAL_OPTIONS, TERMINAL_OPTIONS,
TERMINAL_RESIZE_DELAY_MS, TERMINAL_RESIZE_DELAY_MS,
} from '../constants/constants'; } from '../constants/constants';
import { copyTextToClipboard } from '../../../utils/clipboard'; import {
import { isCodexLoginCommand } from '../utils/auth'; installMobileTerminalSelection,
type MobileTerminalSelectionManager,
} from '../utils/mobileTerminalSelection';
import { sendSocketMessage } from '../utils/socket'; import { sendSocketMessage } from '../utils/socket';
import { ensureXtermFocusStyles } from '../utils/terminalStyles'; import { ensureXtermFocusStyles } from '../utils/terminalStyles';
@@ -24,10 +27,6 @@ type UseShellTerminalOptions = {
selectedProject: Project | null | undefined; selectedProject: Project | null | undefined;
minimal: boolean; minimal: boolean;
isRestarting: boolean; isRestarting: boolean;
initialCommandRef: MutableRefObject<string | null | undefined>;
isPlainShellRef: MutableRefObject<boolean>;
authUrlRef: MutableRefObject<string>;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
closeSocket: () => void; closeSocket: () => void;
}; };
@@ -45,14 +44,11 @@ export function useShellTerminal({
selectedProject, selectedProject,
minimal, minimal,
isRestarting, isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket, closeSocket,
}: UseShellTerminalOptions): UseShellTerminalResult { }: UseShellTerminalOptions): UseShellTerminalResult {
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const resizeTimeoutRef = useRef<number | null>(null); const resizeTimeoutRef = useRef<number | null>(null);
const mobileSelectionRef = useRef<MobileTerminalSelectionManager | null>(null);
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || ''; const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
const hasSelectedProject = Boolean(selectedProject); const hasSelectedProject = Boolean(selectedProject);
@@ -70,6 +66,11 @@ export function useShellTerminal({
}, [terminalRef]); }, [terminalRef]);
const disposeTerminal = useCallback(() => { const disposeTerminal = useCallback(() => {
if (mobileSelectionRef.current) {
mobileSelectionRef.current.dispose();
mobileSelectionRef.current = null;
}
if (terminalRef.current) { if (terminalRef.current) {
terminalRef.current.dispose(); terminalRef.current.dispose();
terminalRef.current = null; terminalRef.current = null;
@@ -80,7 +81,8 @@ export function useShellTerminal({
}, [fitAddonRef, terminalRef]); }, [fitAddonRef, terminalRef]);
useEffect(() => { useEffect(() => {
if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) { const terminalContainer = terminalContainerRef.current;
if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) {
return; return;
} }
@@ -102,7 +104,28 @@ export function useShellTerminal({
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback'); console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
} }
nextTerminal.open(terminalContainerRef.current); nextTerminal.open(terminalContainer);
mobileSelectionRef.current = installMobileTerminalSelection(
nextTerminal,
terminalContainer,
{
onFontSizeChange: (fontSize) => {
nextTerminal.options.fontSize = fontSize;
const currentFitAddon = fitAddonRef.current;
if (currentFitAddon) {
currentFitAddon.fit();
sendSocketMessage(wsRef.current, {
type: 'resize',
cols: nextTerminal.cols,
rows: nextTerminal.rows,
});
} else {
nextTerminal.refresh(0, nextTerminal.rows - 1);
}
},
},
);
const copyTerminalSelection = async () => { const copyTerminalSelection = async () => {
const selection = nextTerminal.getSelection(); const selection = nextTerminal.getSelection();
@@ -133,29 +156,9 @@ export function useShellTerminal({
void copyTextToClipboard(selection); void copyTextToClipboard(selection);
}; };
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy); terminalContainer.addEventListener('copy', handleTerminalCopy);
nextTerminal.attachCustomKeyEventHandler((event) => { nextTerminal.attachCustomKeyEventHandler((event) => {
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
? CODEX_DEVICE_AUTH_URL
: authUrlRef.current;
if (
event.type === 'keydown' &&
minimal &&
isPlainShellRef.current &&
activeAuthUrl &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
event.key?.toLowerCase() === 'c'
) {
event.preventDefault();
event.stopPropagation();
void copyAuthUrlToClipboard(activeAuthUrl);
return false;
}
if ( if (
event.type === 'keydown' && event.type === 'keydown' &&
(event.ctrlKey || event.metaKey) && (event.ctrlKey || event.metaKey) &&
@@ -240,10 +243,10 @@ export function useShellTerminal({
}, TERMINAL_RESIZE_DELAY_MS); }, TERMINAL_RESIZE_DELAY_MS);
}); });
resizeObserver.observe(terminalContainerRef.current); resizeObserver.observe(terminalContainer);
return () => { return () => {
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy); terminalContainer.removeEventListener('copy', handleTerminalCopy);
resizeObserver.disconnect(); resizeObserver.disconnect();
if (resizeTimeoutRef.current !== null) { if (resizeTimeoutRef.current !== null) {
window.clearTimeout(resizeTimeoutRef.current); window.clearTimeout(resizeTimeoutRef.current);
@@ -254,16 +257,12 @@ export function useShellTerminal({
disposeTerminal(); disposeTerminal();
}; };
}, [ }, [
authUrlRef,
closeSocket, closeSocket,
copyAuthUrlToClipboard,
disposeTerminal, disposeTerminal,
fitAddonRef, fitAddonRef,
initialCommandRef,
isPlainShellRef,
isRestarting, isRestarting,
minimal,
hasSelectedProject, hasSelectedProject,
minimal,
selectedProjectKey, selectedProjectKey,
terminalContainerRef, terminalContainerRef,
terminalRef, terminalRef,

View File

@@ -4,8 +4,6 @@ import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app'; import type { Project, ProjectSession } from '../../../types/app';
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
export type ShellInitMessage = { export type ShellInitMessage = {
type: 'init'; type: 'init';
projectPath: string; projectPath: string;
@@ -54,7 +52,6 @@ export type ShellSharedRefs = {
wsRef: MutableRefObject<WebSocket | null>; wsRef: MutableRefObject<WebSocket | null>;
terminalRef: MutableRefObject<Terminal | null>; terminalRef: MutableRefObject<Terminal | null>;
fitAddonRef: MutableRefObject<FitAddon | null>; fitAddonRef: MutableRefObject<FitAddon | null>;
authUrlRef: MutableRefObject<string>;
selectedProjectRef: MutableRefObject<Project | null | undefined>; selectedProjectRef: MutableRefObject<Project | null | undefined>;
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>; selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
initialCommandRef: MutableRefObject<string | null | undefined>; initialCommandRef: MutableRefObject<string | null | undefined>;
@@ -69,10 +66,6 @@ export type UseShellRuntimeResult = {
isConnected: boolean; isConnected: boolean;
isInitialized: boolean; isInitialized: boolean;
isConnecting: boolean; isConnecting: boolean;
authUrl: string;
authUrlVersion: number;
connectToShell: (options?: { forceRestart?: boolean }) => void; connectToShell: (options?: { forceRestart?: boolean }) => void;
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void; disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
openAuthUrlInBrowser: (url?: string) => boolean;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
}; };

View File

@@ -1,17 +1,4 @@
import type { ProjectSession } from '../../../types/app'; import type { ProjectSession } from '../../../types/app';
import { CODEX_DEVICE_AUTH_URL } from '../constants/constants';
export function isCodexLoginCommand(command: string | null | undefined): boolean {
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
}
export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string {
if (isCodexLoginCommand(command)) {
return CODEX_DEVICE_AUTH_URL;
}
return authUrl;
}
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null { export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
if (!session) { if (!session) {
@@ -21,4 +8,4 @@ export function getSessionDisplayName(session: ProjectSession | null | undefined
return session.__provider === 'cursor' return session.__provider === 'cursor'
? session.name || 'Untitled Session' ? session.name || 'Untitled Session'
: session.summary || 'New Session'; : session.summary || 'New Session';
} }

File diff suppressed because it is too large Load Diff

View File

@@ -59,12 +59,8 @@ export default function Shell({
isConnected, isConnected,
isInitialized, isInitialized,
isConnecting, isConnecting,
authUrl,
authUrlVersion,
connectToShell, connectToShell,
disconnectFromShell, disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
} = useShellRuntime({ } = useShellRuntime({
selectedProject, selectedProject,
selectedSession, selectedSession,
@@ -243,15 +239,7 @@ export default function Shell({
if (minimal) { if (minimal) {
return ( return (
<> <>
<ShellMinimalView <ShellMinimalView terminalContainerRef={terminalContainerRef} />
terminalContainerRef={terminalContainerRef}
authUrl={authUrl}
authUrlVersion={authUrlVersion}
initialCommand={initialCommand}
isConnected={isConnected}
openAuthUrlInBrowser={openAuthUrlInBrowser}
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
/>
<TerminalShortcutsPanel <TerminalShortcutsPanel
wsRef={wsRef} wsRef={wsRef}
terminalRef={terminalRef} terminalRef={terminalRef}
@@ -322,7 +310,7 @@ export default function Shell({
{cliPromptOptions && isConnected && ( {cliPromptOptions && isConnected && (
<div <div
className="absolute inset-x-0 bottom-0 z-10 border-t border-gray-700/80 bg-gray-800/95 px-3 py-2 backdrop-blur-sm" className="absolute inset-x-0 bottom-0 z-10 border-t border-gray-700/80 bg-gray-800/95 px-3 py-2 backdrop-blur-sm md:hidden"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">

View File

@@ -1,45 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import type { AuthCopyStatus } from '../../types/types';
import { resolveAuthUrlForDisplay } from '../../utils/auth';
type ShellMinimalViewProps = { type ShellMinimalViewProps = {
terminalContainerRef: RefObject<HTMLDivElement>; terminalContainerRef: RefObject<HTMLDivElement>;
authUrl: string;
authUrlVersion: number;
initialCommand: string | null | undefined;
isConnected: boolean;
openAuthUrlInBrowser: (url: string) => boolean;
copyAuthUrlToClipboard: (url: string) => Promise<boolean>;
}; };
export default function ShellMinimalView({ export default function ShellMinimalView({
terminalContainerRef, terminalContainerRef,
authUrl,
authUrlVersion,
initialCommand,
isConnected,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
}: ShellMinimalViewProps) { }: ShellMinimalViewProps) {
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState<AuthCopyStatus>('idle');
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
const displayAuthUrl = useMemo(
() => resolveAuthUrlForDisplay(initialCommand, authUrl),
[authUrl, initialCommand],
);
// Keep auth panel UI state local to minimal mode and reset it when connection/url changes.
useEffect(() => {
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
}, [authUrlVersion, displayAuthUrl, isConnected]);
const hasAuthUrl = Boolean(displayAuthUrl);
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
return ( return (
<div className="relative h-full w-full bg-gray-900"> <div className="relative h-full w-full bg-gray-900">
<div <div
@@ -47,67 +14,6 @@ export default function ShellMinimalView({
className="h-full w-full focus:outline-none" className="h-full w-full focus:outline-none"
style={{ outline: 'none' }} style={{ outline: 'none' }}
/> />
{showMobileAuthPanel && (
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
<button
type="button"
onClick={() => setIsAuthPanelHidden(true)}
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
>
Hide
</button>
</div>
<input
type="text"
value={displayAuthUrl}
readOnly
onClick={(event) => event.currentTarget.select()}
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
aria-label="Authentication URL"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
openAuthUrlInBrowser(displayAuthUrl);
}}
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
>
Open URL
</button>
<button
type="button"
onClick={async () => {
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
}}
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
>
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
</button>
</div>
</div>
</div>
)}
{showMobileAuthPanelToggle && (
<div className="absolute bottom-14 right-3 z-20 md:hidden">
<button
type="button"
onClick={() => setIsAuthPanelHidden(false)}
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
>
Show login URL
</button>
</div>
)}
</div> </div>
); );
} }

View File

@@ -26,6 +26,7 @@ const MOBILE_KEYS: Shortcut[] = [
{ type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' }, { type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' },
{ type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' }, { type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' },
{ type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' }, { type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' },
{ type: 'key', id: 'ctrl-c', label: 'Ctrl+C', sequence: '\x03' },
]; ];
const ARROW_ICONS = { const ARROW_ICONS = {

View File

@@ -2,6 +2,7 @@ import { Activity, Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw,
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { Button, Input, Tooltip } from '../../../../shared/view/ui'; import { Button, Input, Tooltip } from '../../../../shared/view/ui';
import { CLOUDCLI_WORDMARK_FONT_FAMILY } from '../../../../constants/branding';
import { IS_PLATFORM } from '../../../../constants/config'; import { IS_PLATFORM } from '../../../../constants/config';
import { cn } from '../../../../lib/utils'; import { cn } from '../../../../lib/utils';
import type { SidebarSearchMode } from '../../types/types'; import type { SidebarSearchMode } from '../../types/types';
@@ -67,7 +68,12 @@ export default function SidebarHeader({
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg> </svg>
</div> </div>
<h1 className="truncate text-sm font-normal tracking-tight text-foreground">{t('app.title')}</h1> <h1
className="truncate text-sm font-bold tracking-tight text-foreground"
style={{ fontFamily: CLOUDCLI_WORDMARK_FONT_FAMILY }}
>
{t('app.title')}
</h1>
</div> </div>
); );

View File

@@ -157,7 +157,7 @@ export default function SidebarSessionItem({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="min-w-0 flex-1 truncate text-xs font-normal text-foreground">{sessionView.sessionName}</div> <div className="min-w-0 flex-1 truncate text-sm font-normal text-foreground">{sessionView.sessionName}</div>
{isProcessing ? ( {isProcessing ? (
<span className="ml-auto flex-shrink-0"> <span className="ml-auto flex-shrink-0">
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top"> <Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
@@ -226,7 +226,7 @@ export default function SidebarSessionItem({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="min-w-0 flex-1 truncate text-xs font-normal text-foreground">{sessionView.sessionName}</div> <div className="min-w-0 flex-1 truncate text-sm font-normal text-foreground">{sessionView.sessionName}</div>
{isProcessing ? ( {isProcessing ? (
<span <span
className={cn( className={cn(

View File

@@ -0,0 +1,2 @@
export const CLOUDCLI_WORDMARK_FONT_FAMILY =
'ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji';

View File

@@ -41,7 +41,7 @@ export const ThemeProvider = ({ children }) => {
const themeColorMeta = document.querySelector('meta[name="theme-color"]'); const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) { if (themeColorMeta) {
themeColorMeta.setAttribute('content', '#0c1117'); // Dark background color (hsl(222.2 84% 4.9%)) themeColorMeta.setAttribute('content', '#141414'); // Dark background color (hsl(0 0% 8%))
} }
} else { } else {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
@@ -55,7 +55,7 @@ export const ThemeProvider = ({ children }) => {
const themeColorMeta = document.querySelector('meta[name="theme-color"]'); const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) { if (themeColorMeta) {
themeColorMeta.setAttribute('content', '#ffffff'); // Light background color themeColorMeta.setAttribute('content', '#f6f4ef'); // Light background color (warm cream)
} }
} }
}, [isDarkMode]); }, [isDarkMode]);

View File

@@ -1,10 +1,8 @@
import { useEffect, useReducer, useRef } from 'react'; import { useEffect, useReducer, useRef } from 'react';
type UiPreferences = { type UiPreferences = {
autoExpandTools: boolean;
showRawParameters: boolean; showRawParameters: boolean;
showThinking: boolean; showThinking: boolean;
autoScrollToBottom: boolean;
sendByCtrlEnter: boolean; sendByCtrlEnter: boolean;
sidebarVisible: boolean; sidebarVisible: boolean;
voiceEnabled: boolean; voiceEnabled: boolean;
@@ -34,10 +32,8 @@ type UiPreferencesAction =
| ResetPreferencesAction; | ResetPreferencesAction;
const DEFAULTS: UiPreferences = { const DEFAULTS: UiPreferences = {
autoExpandTools: false,
showRawParameters: false, showRawParameters: false,
showThinking: true, showThinking: true,
autoScrollToBottom: true,
sendByCtrlEnter: false, sendByCtrlEnter: false,
sidebarVisible: true, sidebarVisible: true,
voiceEnabled: false, voiceEnabled: false,

View File

@@ -54,14 +54,11 @@
"sections": { "sections": {
"appearance": "Darstellung", "appearance": "Darstellung",
"toolDisplay": "Werkzeuganzeige", "toolDisplay": "Werkzeuganzeige",
"viewOptions": "Anzeigeoptionen",
"inputSettings": "Eingabeeinstellungen" "inputSettings": "Eingabeeinstellungen"
}, },
"darkMode": "Darkmode", "darkMode": "Darkmode",
"autoExpandTools": "Werkzeuge automatisch erweitern",
"showRawParameters": "Rohe Parameter anzeigen", "showRawParameters": "Rohe Parameter anzeigen",
"showThinking": "Denken anzeigen", "showThinking": "Denken anzeigen",
"autoScrollToBottom": "Automatisch nach unten scrollen",
"sendByCtrlEnter": "Mit Strg+Enter senden", "sendByCtrlEnter": "Mit Strg+Enter senden",
"sendByCtrlEnterDescription": "Wenn aktiviert, sendet Strg+Enter die Nachricht anstelle von Enter. Dies ist nützlich für IME-Benutzer:innen, um versehentliches Senden zu vermeiden.", "sendByCtrlEnterDescription": "Wenn aktiviert, sendet Strg+Enter die Nachricht anstelle von Enter. Dies ist nützlich für IME-Benutzer:innen, um versehentliches Senden zu vermeiden.",
"dragHandle": { "dragHandle": {

View File

@@ -32,5 +32,10 @@
"binaryFile": { "binaryFile": {
"title": "Binary File", "title": "Binary File",
"message": "The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file." "message": "The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file."
},
"filePreview": {
"loading": "Loading preview...",
"error": "Unable to display this file.",
"openInNewTab": "Open in new tab"
} }
} }

View File

@@ -70,14 +70,11 @@
"sections": { "sections": {
"appearance": "Appearance", "appearance": "Appearance",
"toolDisplay": "Tool Display", "toolDisplay": "Tool Display",
"viewOptions": "View Options",
"inputSettings": "Input Settings" "inputSettings": "Input Settings"
}, },
"darkMode": "Dark Mode", "darkMode": "Dark Mode",
"autoExpandTools": "Auto-expand tools",
"showRawParameters": "Show raw parameters", "showRawParameters": "Show raw parameters",
"showThinking": "Show thinking", "showThinking": "Show thinking",
"autoScrollToBottom": "Auto-scroll to bottom",
"sendByCtrlEnter": "Send by Ctrl+Enter", "sendByCtrlEnter": "Send by Ctrl+Enter",
"voiceEnabled": "Voice (mic + read aloud)", "voiceEnabled": "Voice (mic + read aloud)",
"sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.", "sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.",

View File

@@ -54,14 +54,11 @@
"sections": { "sections": {
"appearance": "Apparence", "appearance": "Apparence",
"toolDisplay": "Affichage des outils", "toolDisplay": "Affichage des outils",
"viewOptions": "Options d'affichage",
"inputSettings": "Paramètres de saisie" "inputSettings": "Paramètres de saisie"
}, },
"darkMode": "Mode sombre", "darkMode": "Mode sombre",
"autoExpandTools": "Développer automatiquement les outils",
"showRawParameters": "Afficher les paramètres bruts", "showRawParameters": "Afficher les paramètres bruts",
"showThinking": "Afficher la réflexion", "showThinking": "Afficher la réflexion",
"autoScrollToBottom": "Défilement automatique vers le bas",
"sendByCtrlEnter": "Envoyer avec Ctrl+Entrée", "sendByCtrlEnter": "Envoyer avec Ctrl+Entrée",
"sendByCtrlEnterDescription": "Lorsqu'activé, appuyer sur Ctrl+Entrée envoie le message au lieu de simplement Entrée. Utile pour les utilisateurs IME pour éviter les envois accidentels.", "sendByCtrlEnterDescription": "Lorsqu'activé, appuyer sur Ctrl+Entrée envoie le message au lieu de simplement Entrée. Utile pour les utilisateurs IME pour éviter les envois accidentels.",
"dragHandle": { "dragHandle": {

View File

@@ -54,14 +54,11 @@
"sections": { "sections": {
"appearance": "Aspetto", "appearance": "Aspetto",
"toolDisplay": "Visualizzazione strumenti", "toolDisplay": "Visualizzazione strumenti",
"viewOptions": "Opzioni visualizzazione",
"inputSettings": "Impostazioni input" "inputSettings": "Impostazioni input"
}, },
"darkMode": "Modalità scura", "darkMode": "Modalità scura",
"autoExpandTools": "Espandi strumenti automaticamente",
"showRawParameters": "Mostra parametri grezzi", "showRawParameters": "Mostra parametri grezzi",
"showThinking": "Mostra ragionamento", "showThinking": "Mostra ragionamento",
"autoScrollToBottom": "Scorrimento automatico in basso",
"sendByCtrlEnter": "Invia con Ctrl+Invio", "sendByCtrlEnter": "Invia con Ctrl+Invio",
"sendByCtrlEnterDescription": "Se abilitato, premere Ctrl+Invio invierà il messaggio invece di Invio. Utile per gli utenti IME per evitare invii accidentali.", "sendByCtrlEnterDescription": "Se abilitato, premere Ctrl+Invio invierà il messaggio invece di Invio. Utile per gli utenti IME per evitare invii accidentali.",
"dragHandle": { "dragHandle": {

View File

@@ -54,14 +54,11 @@
"sections": { "sections": {
"appearance": "外観", "appearance": "外観",
"toolDisplay": "ツール表示", "toolDisplay": "ツール表示",
"viewOptions": "表示オプション",
"inputSettings": "入力設定" "inputSettings": "入力設定"
}, },
"darkMode": "ダークモード", "darkMode": "ダークモード",
"autoExpandTools": "ツールを自動展開",
"showRawParameters": "生パラメータを表示", "showRawParameters": "生パラメータを表示",
"showThinking": "思考を表示", "showThinking": "思考を表示",
"autoScrollToBottom": "自動スクロール",
"sendByCtrlEnter": "Ctrl+Enterで送信", "sendByCtrlEnter": "Ctrl+Enterで送信",
"sendByCtrlEnterDescription": "有効にすると、Enterではなく Ctrl+Enter でメッセージを送信します。IMEユーザーの誤送信防止に便利です。", "sendByCtrlEnterDescription": "有効にすると、Enterではなく Ctrl+Enter でメッセージを送信します。IMEユーザーの誤送信防止に便利です。",
"dragHandle": { "dragHandle": {

View File

@@ -54,14 +54,11 @@
"sections": { "sections": {
"appearance": "외관", "appearance": "외관",
"toolDisplay": "도구 표시", "toolDisplay": "도구 표시",
"viewOptions": "보기 옵션",
"inputSettings": "입력 설정" "inputSettings": "입력 설정"
}, },
"darkMode": "다크 모드", "darkMode": "다크 모드",
"autoExpandTools": "도구 자동 펼치기",
"showRawParameters": "Raw 파라미터 표시", "showRawParameters": "Raw 파라미터 표시",
"showThinking": "생각 과정 표시", "showThinking": "생각 과정 표시",
"autoScrollToBottom": "자동 스크롤",
"sendByCtrlEnter": "Ctrl+Enter로 전송", "sendByCtrlEnter": "Ctrl+Enter로 전송",
"sendByCtrlEnterDescription": "활성화하면 Enter 대신 Ctrl+Enter로 메시지를 전송합니다. IME 사용자가 실수로 전송하는 것을 방지하는 데 유용합니다.", "sendByCtrlEnterDescription": "활성화하면 Enter 대신 Ctrl+Enter로 메시지를 전송합니다. IME 사용자가 실수로 전송하는 것을 방지하는 데 유용합니다.",
"dragHandle": { "dragHandle": {

View File

@@ -54,14 +54,11 @@
"sections": { "sections": {
"appearance": "Внешний вид", "appearance": "Внешний вид",
"toolDisplay": "Отображение инструментов", "toolDisplay": "Отображение инструментов",
"viewOptions": "Параметры просмотра",
"inputSettings": "Настройки ввода" "inputSettings": "Настройки ввода"
}, },
"darkMode": "Темная тема", "darkMode": "Темная тема",
"autoExpandTools": "Автоматически разворачивать инструменты",
"showRawParameters": "Показывать сырые параметры", "showRawParameters": "Показывать сырые параметры",
"showThinking": "Показывать размышления", "showThinking": "Показывать размышления",
"autoScrollToBottom": "Автопрокрутка вниз",
"sendByCtrlEnter": "Отправка по Ctrl+Enter", "sendByCtrlEnter": "Отправка по Ctrl+Enter",
"sendByCtrlEnterDescription": "Когда включено, нажатие Ctrl+Enter будет отправлять сообщение вместо просто Enter. Это полезно для пользователей IME, чтобы избежать случайной отправки.", "sendByCtrlEnterDescription": "Когда включено, нажатие Ctrl+Enter будет отправлять сообщение вместо просто Enter. Это полезно для пользователей IME, чтобы избежать случайной отправки.",
"dragHandle": { "dragHandle": {

View File

@@ -54,14 +54,11 @@
"sections": { "sections": {
"appearance": "Görünüm", "appearance": "Görünüm",
"toolDisplay": "Araç Gösterimi", "toolDisplay": "Araç Gösterimi",
"viewOptions": "Görünüm Seçenekleri",
"inputSettings": "Girdi Ayarları" "inputSettings": "Girdi Ayarları"
}, },
"darkMode": "Koyu Mod", "darkMode": "Koyu Mod",
"autoExpandTools": "Araçları otomatik genişlet",
"showRawParameters": "Ham parametreleri göster", "showRawParameters": "Ham parametreleri göster",
"showThinking": "Düşünmeyi göster", "showThinking": "Düşünmeyi göster",
"autoScrollToBottom": "Otomatik en alta kaydır",
"sendByCtrlEnter": "Ctrl+Enter ile gönder", "sendByCtrlEnter": "Ctrl+Enter ile gönder",
"sendByCtrlEnterDescription": "Etkinleştirildiğinde, Ctrl+Enter'a basmak yalnız Enter yerine mesajı gönderir. IME (girdi metot düzenleyici) kullananlar için yanlışlıkla göndermeyi önler.", "sendByCtrlEnterDescription": "Etkinleştirildiğinde, Ctrl+Enter'a basmak yalnız Enter yerine mesajı gönderir. IME (girdi metot düzenleyici) kullananlar için yanlışlıkla göndermeyi önler.",
"dragHandle": { "dragHandle": {

View File

@@ -54,14 +54,11 @@
"sections": { "sections": {
"appearance": "外观", "appearance": "外观",
"toolDisplay": "工具显示", "toolDisplay": "工具显示",
"viewOptions": "视图选项",
"inputSettings": "输入设置" "inputSettings": "输入设置"
}, },
"darkMode": "深色模式", "darkMode": "深色模式",
"autoExpandTools": "自动展开工具",
"showRawParameters": "显示原始参数", "showRawParameters": "显示原始参数",
"showThinking": "显示思考过程", "showThinking": "显示思考过程",
"autoScrollToBottom": "自动滚动到底部",
"sendByCtrlEnter": "使用 Ctrl+Enter 发送", "sendByCtrlEnter": "使用 Ctrl+Enter 发送",
"sendByCtrlEnterDescription": "启用后,按 Ctrl+Enter 发送消息,而不是仅按 Enter。这对于使用输入法的用户可以避免意外发送。", "sendByCtrlEnterDescription": "启用后,按 Ctrl+Enter 发送消息,而不是仅按 Enter。这对于使用输入法的用户可以避免意外发送。",
"dragHandle": { "dragHandle": {

View File

@@ -54,14 +54,11 @@
"sections": { "sections": {
"appearance": "外觀", "appearance": "外觀",
"toolDisplay": "工具顯示", "toolDisplay": "工具顯示",
"viewOptions": "檢視選項",
"inputSettings": "輸入設定" "inputSettings": "輸入設定"
}, },
"darkMode": "深色模式", "darkMode": "深色模式",
"autoExpandTools": "自動展開工具",
"showRawParameters": "顯示原始參數", "showRawParameters": "顯示原始參數",
"showThinking": "顯示思考過程", "showThinking": "顯示思考過程",
"autoScrollToBottom": "自動捲動到底部",
"sendByCtrlEnter": "使用 Ctrl+Enter 傳送", "sendByCtrlEnter": "使用 Ctrl+Enter 傳送",
"sendByCtrlEnterDescription": "啟用後,按 Ctrl+Enter 傳送訊息,而不是僅按 Enter。這對於使用輸入法的使用者可以避免意外傳送。", "sendByCtrlEnterDescription": "啟用後,按 Ctrl+Enter 傳送訊息,而不是僅按 Enter。這對於使用輸入法的使用者可以避免意外傳送。",
"dragHandle": { "dragHandle": {

View File

@@ -23,37 +23,37 @@
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 44 22% 96%;
--foreground: 222.2 84% 4.9%; --foreground: 36 25% 4%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 36 25% 4%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 36 25% 4%;
--primary: 221.2 83.2% 53.3%; --primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%; --primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%; --secondary: 44 15% 91%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 36 15% 18%;
--muted: 210 40% 96.1%; --muted: 44 15% 91%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 40 5% 44%;
--accent: 210 40% 96.1%; --accent: 44 15% 91%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 36 15% 18%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%; --border: 44 14% 87%;
--input: 214.3 31.8% 91.4%; --input: 44 14% 87%;
--ring: 221.2 83.2% 53.3%; --ring: 221.2 83.2% 53.3%;
--radius: 0.5rem; --radius: 0.5rem;
/* Nav design tokens */ /* Nav design tokens */
--nav-glass-bg: 0 0% 100% / 0.7; --nav-glass-bg: 44 22% 96% / 0.7;
--nav-glass-blur: 20px; --nav-glass-blur: 20px;
--nav-glass-saturate: 1.8; --nav-glass-saturate: 1.8;
--nav-tab-glow: 221.2 83.2% 53.3% / 0.18; --nav-tab-glow: 221.2 83.2% 53.3% / 0.18;
--nav-tab-ring: 221.2 83.2% 53.3% / 0.10; --nav-tab-ring: 221.2 83.2% 53.3% / 0.10;
--nav-float-shadow: 0 0% 0% / 0.06; --nav-float-shadow: 0 0% 0% / 0.06;
--nav-float-ring: 214.3 31.8% 91.4% / 0.5; --nav-float-ring: 44 14% 87% / 0.5;
--nav-divider-color: 214.3 31.8% 91.4% / 0.5; --nav-divider-color: 44 14% 87% / 0.5;
--nav-input-bg: 210 40% 96.1% / 0.5; --nav-input-bg: 44 15% 91% / 0.5;
--nav-input-focus-ring: 221.2 83.2% 53.3% / 0.22; --nav-input-focus-ring: 221.2 83.2% 53.3% / 0.22;
/* Safe area CSS variables */ /* Safe area CSS variables */
@@ -85,36 +85,36 @@
} }
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 0 0% 8%;
--foreground: 210 40% 98%; --foreground: 40 8% 93%;
--card: 217.2 91.2% 8%; --card: 0 0% 12%;
--card-foreground: 210 40% 98%; --card-foreground: 40 8% 93%;
--popover: 217.2 91.2% 8%; --popover: 0 0% 12%;
--popover-foreground: 210 40% 98%; --popover-foreground: 40 8% 93%;
--primary: 217.2 91.2% 59.8%; --primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 0 0% 8%;
--secondary: 217.2 32.6% 17.5%; --secondary: 0 0% 17%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 40 8% 93%;
--muted: 217.2 32.6% 17.5%; --muted: 0 0% 17%;
--muted-foreground: 215 20.2% 65.1%; --muted-foreground: 0 0% 60%;
--accent: 217.2 32.6% 17.5%; --accent: 0 0% 17%;
--accent-foreground: 210 40% 98%; --accent-foreground: 40 8% 93%;
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 40 8% 93%;
--border: 217.2 32.6% 17.5%; --border: 0 0% 17%;
--input: 220 13% 46%; --input: 0 0% 23%;
--ring: 217.2 91.2% 59.8%; --ring: 217.2 91.2% 59.8%;
/* Nav design tokens — dark overrides */ /* Nav design tokens — dark overrides */
--nav-glass-bg: 217.2 91.2% 8% / 0.55; --nav-glass-bg: 0 0% 12% / 0.55;
--nav-glass-blur: 24px; --nav-glass-blur: 24px;
--nav-glass-saturate: 1.6; --nav-glass-saturate: 1.6;
--nav-tab-glow: 217.2 91.2% 59.8% / 0.25; --nav-tab-glow: 217.2 91.2% 59.8% / 0.25;
--nav-tab-ring: 217.2 91.2% 59.8% / 0.15; --nav-tab-ring: 217.2 91.2% 59.8% / 0.15;
--nav-float-shadow: 0 0% 0% / 0.35; --nav-float-shadow: 0 0% 0% / 0.35;
--nav-float-ring: 217.2 32.6% 17.5% / 0.3; --nav-float-ring: 0 0% 17% / 0.3;
--nav-divider-color: 217.2 32.6% 17.5% / 0.5; --nav-divider-color: 0 0% 17% / 0.5;
--nav-input-bg: 217.2 32.6% 17.5% / 0.5; --nav-input-bg: 0 0% 17% / 0.5;
--nav-input-focus-ring: 217.2 91.2% 59.8% / 0.25; --nav-input-focus-ring: 217.2 91.2% 59.8% / 0.25;
} }
} }
@@ -128,7 +128,8 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-family: "Encode Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
margin: 0; margin: 0;
@@ -139,6 +140,12 @@
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
/* The app shell is a fixed inset-0 container (see AppContent), so the
document itself never needs to scroll. Clipping it removes the phantom
full-height page scrollbar and disables the browser pull-to-refresh
gesture that reloads the page when scrolling up on mobile. */
overflow: hidden;
overscroll-behavior-y: contain;
} }
/* Root element with safe area padding for PWA */ /* Root element with safe area padding for PWA */
@@ -344,7 +351,7 @@
} }
.dark .scrollbar-thin::-webkit-scrollbar-track { .dark .scrollbar-thin::-webkit-scrollbar-track {
background: rgba(31, 41, 55, 0.3); background: rgba(38, 38, 38, 0.3);
} }
.dark .scrollbar-thin::-webkit-scrollbar-thumb { .dark .scrollbar-thin::-webkit-scrollbar-thumb {
@@ -363,7 +370,7 @@
} }
.dark::-webkit-scrollbar-track { .dark::-webkit-scrollbar-track {
background: rgba(31, 41, 55, 0.5); background: rgba(38, 38, 38, 0.5);
} }
.dark::-webkit-scrollbar-thumb { .dark::-webkit-scrollbar-thumb {
@@ -378,7 +385,7 @@
/* Firefox scrollbar styles */ /* Firefox scrollbar styles */
.dark { .dark {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(107, 114, 128, 0.5) rgba(31, 41, 55, 0.5); scrollbar-color: rgba(115, 115, 115, 0.5) rgba(38, 38, 38, 0.5);
} }
/* Ensure checkbox styling is preserved */ /* Ensure checkbox styling is preserved */
@@ -469,7 +476,7 @@
/* Fix focus-within container issues in dark mode */ /* Fix focus-within container issues in dark mode */
.dark .focus-within\:ring-2:focus-within { .dark .focus-within\:ring-2:focus-within {
background-color: rgb(31 41 55) !important; /* gray-800 */ background-color: rgb(20 20 20) !important;
} }
/* Ensure textarea remains transparent with visible text */ /* Ensure textarea remains transparent with visible text */
@@ -562,7 +569,23 @@
} }
.chat-composer-shell { .chat-composer-shell {
contain: layout style paint; contain: layout style;
}
.chat-activity-enter {
animation: chat-activity-enter 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
transform-origin: bottom center;
will-change: transform, opacity, filter;
}
.chat-activity-exit {
animation: chat-activity-exit 220ms cubic-bezier(0.4, 0, 1, 1) both;
transform-origin: bottom center;
will-change: transform, opacity, filter;
}
.chat-activity-tab {
clip-path: inset(-8px -8px 0 -8px);
} }
.chat-message { .chat-message {
@@ -867,12 +890,12 @@
/* Fix focus ring offset color in dark mode */ /* Fix focus ring offset color in dark mode */
.dark [class*="ring-offset"] { .dark [class*="ring-offset"] {
--tw-ring-offset-color: rgb(31 41 55); /* gray-800 */ --tw-ring-offset-color: rgb(20 20 20);
} }
/* Ensure buttons don't show white backgrounds in dark mode */ /* Ensure buttons don't show white backgrounds in dark mode */
.dark button:focus { .dark button:focus {
--tw-ring-offset-color: rgb(31 41 55); /* gray-800 */ --tw-ring-offset-color: rgb(20 20 20);
} }
/* Fix mobile select dropdown styling */ /* Fix mobile select dropdown styling */
@@ -915,8 +938,8 @@
} }
.dark select option { .dark select option {
background-color: rgb(31 41 55) !important; background-color: rgb(31 31 31) !important;
color: rgb(243 244 246) !important; color: rgb(237 235 230) !important;
} }
/* Tool details chevron animation */ /* Tool details chevron animation */
@@ -941,6 +964,37 @@
animation: settings-fade-in 150ms ease-out; animation: settings-fade-in 150ms ease-out;
} }
@keyframes chat-activity-enter {
0% {
opacity: 0;
filter: blur(3px);
transform: translateY(18px) scaleY(0.92);
}
65% {
opacity: 1;
filter: blur(0);
transform: translateY(-2px) scaleY(1.01);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0) scaleY(1);
}
}
@keyframes chat-activity-exit {
0% {
opacity: 1;
filter: blur(0);
transform: translateY(0) scaleY(1);
}
100% {
opacity: 0;
filter: blur(2px);
transform: translateY(14px) scaleY(0.96);
}
}
/* Search result highlight flash */ /* Search result highlight flash */
.search-highlight-flash { .search-highlight-flash {
animation: search-flash 4s ease-out; animation: search-flash 4s ease-out;

View File

@@ -14,6 +14,10 @@ export default {
}, },
}, },
extend: { extend: {
fontFamily: {
sans: ['"Encode Sans"', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
serif: ['Merriweather', 'Georgia', 'Cambria', '"Times New Roman"', 'serif'],
},
colors: { colors: {
border: "hsl(var(--border))", border: "hsl(var(--border))",
input: "hsl(var(--input))", input: "hsl(var(--input))",