Merge branch 'main' into camoufox-novnc-browser-use

This commit is contained in:
Haile
2026-07-01 16:57:16 +03:00
committed by GitHub
79 changed files with 1254 additions and 559 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

@@ -7,6 +7,14 @@
<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" /> <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

@@ -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>
<div className="group relative">
{Icon && (
<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" />
)}
<input <input
id={id} id={id}
type={type} type={resolvedType}
name={name ?? id} name={name ?? id}
autoComplete={autoComplete} autoComplete={autoComplete}
value={value} value={value}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
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" 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} placeholder={placeholder}
required required
disabled={isDisabled} 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

@@ -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);
if (!allMessagesLoadedRef.current) {
const scrolledNearTop = container.scrollTop < 100; 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 (!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,12 +354,26 @@ 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}
/> />
<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 <ChatComposer
pendingPermissionRequests={pendingPermissionRequests} pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision} handlePermissionDecision={handlePermissionDecision}
@@ -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:
@@ -431,6 +441,7 @@ function ChatInterface({
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 ${
isExiting ? 'chat-activity-exit' : 'chat-activity-enter'
}`}
>
<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 /> <span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
<Shimmer className="text-xs font-medium">{`${label}`}</Shimmer> <Shimmer className="font-medium">{`${label}`}</Shimmer>
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span> <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);
} }
const intrinsicKey = getIntrinsicMessageKey(message);
let candidateKey = intrinsicKey;
if (!candidateKey || allocatedKeysRef.current.has(candidateKey)) {
do {
generatedMessageKeyCounterRef.current += 1;
candidateKey = intrinsicKey
? `${intrinsicKey}-${generatedMessageKeyCounterRef.current}`
: `message-generated-${generatedMessageKeyCounterRef.current}`;
} while (allocatedKeysRef.current.has(candidateKey));
} }
return keys;
}, [groupedVisibleMessages]);
allocatedKeysRef.current.add(candidateKey); const getMessageKey = useCallback(
messageKeyMapRef.current.set(message, candidateKey); (message: ChatMessage) =>
return candidateKey; messageKeyMap.get(message) ?? getIntrinsicMessageKey(message) ?? 'message-generated',
}, []); [messageKeyMap],
);
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}
@@ -306,6 +293,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,30 +565,26 @@ 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="relative flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3 sm:items-center">
<div <div
className={`rounded-2xl border border-primary/30 bg-primary/10 text-primary shadow-sm ${ className={`flex shrink-0 items-center justify-center rounded-xl border border-border bg-muted text-foreground ${
isModelsModal ? 'p-2.5' : 'p-3' isModelsModal ? 'h-9 w-9' : 'h-10 w-10'
}`} }`}
> >
<HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} /> <HeaderIcon className={isModelsModal ? 'h-4 w-4' : 'h-5 w-5'} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[12px] font-bold uppercase tracking-[0.22em] text-primary"> <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{activeMeta?.eyebrow} {activeMeta?.eyebrow}
</p> </p>
<p className={`mt-1 font-semibold tracking-tight text-foreground ${isModelsModal ? 'text-xl sm:text-2xl' : 'text-xl sm:text-2xl'}`}> <p className="mt-0.5 text-lg font-semibold tracking-tight text-foreground sm:text-xl">
{activeMeta?.title} {activeMeta?.title}
</p> </p>
<p className={`mt-1 max-w-2xl ${isModelsModal ? 'text-sm leading-5 text-foreground/75' : 'text-sm leading-5 text-muted-foreground'}`}> <p className="mt-0.5 max-w-2xl text-sm leading-5 text-muted-foreground">
{activeMeta?.subtitle} {activeMeta?.subtitle}
</p> </p>
</div> </div>
@@ -599,13 +595,12 @@ export default function CommandResultModal({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onClose} onClick={onClose}
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:bg-background/70 hover:text-foreground" className="h-8 w-8 shrink-0 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close command result modal" aria-label="Close command result modal"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </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">
{payload?.kind === 'help' && <HelpContent data={payload.data as HelpCommandData} />} {payload?.kind === 'help' && <HelpContent data={payload.data as HelpCommandData} />}

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.
const closeOnOutsideClick = (event: MouseEvent) => {
if (!isDropdownOpen) return; if (!isDropdownOpen) return;
// Close when clicking outside both the control and the portaled menu.
const closeOnOutsideClick = (event: MouseEvent) => {
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

@@ -56,7 +56,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;
@@ -229,10 +229,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}

View File

@@ -74,7 +74,7 @@ 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="flex min-w-0 items-center gap-2 text-[11px] leading-tight text-muted-foreground"> <div className="flex min-w-0 items-center gap-2 text-[11px] leading-tight text-muted-foreground">

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}
@@ -169,12 +176,15 @@ 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"
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>
)} )}
<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 ? (
<> <>
@@ -227,6 +237,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
</div> </div>
</div> </div>
</div> </div>
</div>
{activeLoginProvider && ( {activeLoginProvider && (
<ProviderLoginModal <ProviderLoginModal

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

@@ -87,7 +87,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',
@@ -331,7 +330,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

@@ -169,7 +169,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

@@ -310,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

@@ -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;
@@ -350,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 {
@@ -369,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 {
@@ -384,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 */
@@ -475,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 */
@@ -568,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 {
@@ -873,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 */
@@ -921,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 */
@@ -947,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))",