Compare commits

...

24 Commits

Author SHA1 Message Date
Haileyesus
12fb395b4f Merge branch 'feat/design-improvements-and-minor-bug-fixes' of https://github.com/siteboon/claudecodeui into feat/design-improvements-and-minor-bug-fixes 2026-07-01 11:44:07 +03:00
Haileyesus
71ef31441b fix: resolve coderabbit comments 2026-07-01 11:40:39 +03:00
Simos Mikelatos
35d1b636ff Merge branch 'main' into feat/design-improvements-and-minor-bug-fixes 2026-06-30 22:57:53 +02:00
Haileyesus
770404c701 fix: remove unnecessary auto expand tools 2026-06-30 16:12:23 +03:00
Haileyesus
858bed609a fix(chat): correct invalid dark-mode hover on AskUserQuestion options 2026-06-30 15:29:18 +03:00
Haileyesus
1a7f0291b2 style(auth): modernize login, setup, and onboarding screens 2026-06-30 15:01:50 +03:00
Haileyesus
7d5bd753d4 style: improve thinking and stop button placements 2026-06-30 15:01:50 +03:00
Haileyesus
eed37b51d4 fix: align activity indicator with composer input width
Wrap ActivityIndicator in the same mx-auto max-w-3xl container as the
text input so the "Analyzing…" label and Stop button stay within the
input's boundaries instead of spanning the full window width.
2026-06-30 15:01:50 +03:00
Haileyesus
5798246135 style(ui): use Merriweather serif for chat text and Encode Sans for the rest of the UI 2026-06-30 15:01:49 +03:00
Haileyesus
a7b455aeac style(mcp): remove purple accents and portal the server form modal
- Replace the purple provider-button colors, heading icon, and form
  submit button with the primary token (no purple in the MCP UI)
- Portal the add/edit MCP server modal to document.body so its fixed
  overlay covers the full viewport, fixing the white band at the top
  caused by the Settings dialog's transformed tab content becoming the
  containing block
2026-06-30 15:01:49 +03:00
Haileyesus
c420c6d63e fix(chat): header ellipsis, Codex logo on light theme, portal copy menu
- MainContentTitle: truncate the session title with an ellipsis instead
  of horizontal-scrolling it
- MessageComponent: use text-foreground for the provider logo chip so the
  currentColor Codex/OpenAI mark is visible on the light theme
- MessageCopyControl: render the copy-format dropdown in a portal so it
  escapes the chat message's `contain: paint` clip box; anchor it to the
  trigger, flip above near the viewport bottom, close on scroll/resize
2026-06-30 15:01:48 +03:00
Haileyesus
54a062baa6 style(chat): unify composer toolbar heights and declutter slash-command modal
- Composer: give the permission-mode and token-usage buttons a fixed
  h-8 so every bottom-toolbar control shares one height
- CommandResultModal: replace the blue gradient header (gradient fill,
  glow blobs, blue eyebrow + icon chip) with a clean neutral header on
  popover/muted tokens
2026-06-30 15:01:48 +03:00
Haileyesus
9090e73478 fix: use app theme for code editor 2026-06-30 15:01:47 +03:00
Haileyesus
032258b260 style(ui): rework light/dark theme to make it visually consistent
Rework the color system around warm neutrals and route hardcoded
surfaces through theme tokens for consistency.

- Theme tokens (index.css, ThemeContext): warm cream light mode and
  neutral charcoal dark mode, replacing the pure-white/blue-tinted
  palette; update PWA theme-color meta
- Code blocks: soft grey background in light mode via
  oneLight/oneDark, and drop the Tailwind Typography <pre> shell that
  framed the highlighter in a dark box
- Dropdowns/panels: convert CommandMenu, Quick Settings, and the JSON
  response block from hardcoded gray/slate to popover/muted/border
  tokens
- Git panel: Publish button purple -> primary blue
- Composer: drop top padding so the input sits flush with the thread
2026-06-30 15:01:20 +03:00
Haileyesus
e71f3bf3f6 fix(chat): unify messages and composer into centered column
Constrain both ChatMessagesPane content and ChatComposer to the same
max-w-3xl centered column. Previously only
the composer had a max-width, causing messages to fill the full width
while the input stayed narrow, making them visually misaligned with
large empty gutters on either side.
2026-06-30 14:59:03 +03:00
Haileyesus
19b59e701e fix(chat): remove auto scroll quick setting 2026-06-29 22:38:16 +03:00
Haileyesus
37ef891945 fix(chat): hide load all prompt after final page 2026-06-29 22:27:59 +03:00
Haileyesus
f363127427 fix(chat): refine load all overlay behavior 2026-06-29 22:22:46 +03:00
Haileyesus
dc1580dae7 fix: update command menu positioning 2026-06-29 21:38:39 +03:00
Haileyesus
4c6e9178f6 fix(chat): stabilize message scroll controls 2026-06-29 21:37:50 +03:00
Haile
2ebe64f218 fix: preview video on new tab (#933) 2026-06-29 15:36:31 +02:00
Haileyesus
a9e24e7071 fix(chat): group continuous same-tool runs more consistently
Consecutive tool calls (Edit, Read, Grep, etc.) grouped inconsistently:

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

Lower TOOL_GROUP_THRESHOLD to 2 and skip non-rendered messages when
extending a run, so any 2+ consecutive same-tool calls collapse reliably.
ChatMessagesPane now passes showThinking into groupConsecutiveTools.
2026-06-29 15:41:52 +03:00
Haileyesus
2cd1200081 fix(shell): hide prompt options on desktop 2026-06-29 15:27:55 +03:00
Haile
b6cf33308d fix: resolve mobile shell issues (#923) 2026-06-29 14:19:01 +02:00
81 changed files with 2329 additions and 775 deletions

View File

@@ -4,9 +4,17 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<title>CloudCLI UI</title>
<!-- 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 -->
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />

View File

@@ -1,3 +1,5 @@
import { AlertCircle } from 'lucide-react';
type AuthErrorAlertProps = {
errorMessage: string;
};
@@ -8,8 +10,12 @@ export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) {
}
return (
<div className="rounded-md border border-red-300 bg-red-100 p-3 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
<div
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>
);
}

View File

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

View File

@@ -1,30 +1,30 @@
import { MessageSquare } from 'lucide-react';
const loadingDotAnimationDelays = ['0s', '0.1s', '0.2s'];
const loadingDotAnimationDelays = ['0s', '0.15s', '0.3s'];
export default function AuthLoadingScreen() {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="text-center">
<div className="mb-4 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm">
<MessageSquare className="h-8 w-8 text-primary-foreground" />
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
<div aria-hidden className="pointer-events-none absolute 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>
<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>
<h1 className="mb-2 text-2xl font-bold text-foreground">CloudCLI</h1>
<div className="flex items-center justify-center space-x-2">
<h1 className="mb-4 font-serif text-2xl font-bold tracking-tight text-foreground">CloudCLI</h1>
<p className="sr-only">Loading authentication state</p>
<div aria-hidden className="flex items-center justify-center gap-2">
{loadingDotAnimationDelays.map((delay) => (
<div
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 }}
/>
))}
</div>
<p className="mt-2 text-muted-foreground">Loading...</p>
</div>
</div>
);

View File

@@ -1,5 +1,4 @@
import type { ReactNode } from 'react';
import { MessageSquare } from 'lucide-react';
import { IS_PLATFORM } from '../../../constants/config';
type AuthScreenLayoutProps = {
@@ -18,29 +17,38 @@ export default function AuthScreenLayout({
logo,
}: AuthScreenLayoutProps) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="space-y-6 rounded-lg border border-border bg-card p-8 shadow-lg">
<div className="relative h-screen overflow-y-auto bg-background">
{/* Ambient, on-brand backdrop that gives the screen depth without
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="mb-4 flex justify-center">
<div className="mb-5 flex justify-center">
{logo ?? (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm">
<MessageSquare className="h-8 w-8 text-primary-foreground" />
<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>
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
<p className="mt-2 text-muted-foreground">{description}</p>
<h1 className="font-serif text-3xl font-bold tracking-tight text-foreground">{title}</h1>
<p className="mx-auto mt-2 max-w-xs text-sm leading-relaxed text-muted-foreground">{description}</p>
</div>
{children}
<div className="mt-8">{children}</div>
<div className="text-center">
<p className="text-sm text-muted-foreground">{footerText}</p>
<div className="mt-6 border-t border-border/60 pt-5 text-center">
<p className="text-xs leading-relaxed text-muted-foreground">{footerText}</p>
</div>
{!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">
<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>

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import type { FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Lock, User } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import AuthErrorAlert from './AuthErrorAlert';
import AuthInputField from './AuthInputField';
@@ -69,6 +70,7 @@ export default function LoginForm() {
placeholder={t('login.placeholders.username')}
isDisabled={isSubmitting}
autoComplete="username"
icon={User}
/>
<AuthInputField
@@ -80,6 +82,7 @@ export default function LoginForm() {
isDisabled={isSubmitting}
type="password"
autoComplete="current-password"
icon={Lock}
/>
<AuthErrorAlert errorMessage={errorMessage} />
@@ -87,9 +90,16 @@ export default function LoginForm() {
<button
type="submit"
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>
</form>
</AuthScreenLayout>

View File

@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
import type { FormEvent } from 'react';
import { Loader2, Lock, ShieldCheck, User } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import AuthErrorAlert from './AuthErrorAlert';
import AuthInputField from './AuthInputField';
@@ -85,7 +86,6 @@ export default function SetupForm() {
title="Welcome to CloudCLI"
description="Set up your account to get started"
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">
<AuthInputField
@@ -94,9 +94,10 @@ export default function SetupForm() {
label="Username"
value={formState.username}
onChange={(value) => updateField('username', value)}
placeholder="Enter your username"
placeholder="Choose a username"
isDisabled={isSubmitting}
autoComplete="username"
icon={User}
/>
<AuthInputField
@@ -105,10 +106,11 @@ export default function SetupForm() {
label="Password"
value={formState.password}
onChange={(value) => updateField('password', value)}
placeholder="Enter your password"
placeholder="Create a password"
isDisabled={isSubmitting}
type="password"
autoComplete="new-password"
icon={Lock}
/>
<AuthInputField
@@ -117,20 +119,33 @@ export default function SetupForm() {
label="Confirm Password"
value={formState.confirmPassword}
onChange={(value) => updateField('confirmPassword', value)}
placeholder="Confirm your password"
placeholder="Re-enter your password"
isDisabled={isSubmitting}
type="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} />
<button
type="submit"
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>
</form>
</AuthScreenLayout>

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import type { ChatMessage } from '../types/types';
export const TOOL_GROUP_THRESHOLD = 3;
export const TOOL_GROUP_THRESHOLD = 2;
export interface ToolGroupItem {
_isGroup: true;
@@ -19,7 +19,17 @@ function isGroupableToolMessage(message: ChatMessage): message is ChatMessage &
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[] = [];
let index = 0;
@@ -35,13 +45,22 @@ export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[
const run: ChatMessage[] = [message];
let nextIndex = index + 1;
while (
nextIndex < messages.length &&
isGroupableToolMessage(messages[nextIndex]) &&
messages[nextIndex].toolName === message.toolName
) {
run.push(messages[nextIndex]);
nextIndex += 1;
while (nextIndex < messages.length) {
const candidate = messages[nextIndex];
// Skip invisible interleaved messages so they don't break the run.
if (rendersNothing(candidate, showThinking)) {
nextIndex += 1;
continue;
}
if (isGroupableToolMessage(candidate) && candidate.toolName === message.toolName) {
run.push(candidate);
nextIndex += 1;
continue;
}
break;
}
if (run.length >= TOOL_GROUP_THRESHOLD) {

View File

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

View File

@@ -7,6 +7,7 @@ import type { SessionActivity } from '../../../../hooks/useSessionProtection';
type ActivityIndicatorProps = {
activity: SessionActivity | null;
onAbort?: () => void;
isInputFocused?: boolean;
};
const ACTION_KEYS = [
@@ -18,6 +19,7 @@ const ACTION_KEYS = [
'claudeStatus.actions.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
@@ -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
* entry is removed.
*/
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
export default function ActivityIndicator({ activity, onAbort, isInputFocused = false }: ActivityIndicatorProps) {
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);
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(() => {
if (startedAt === null) return;
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);
}, [startedAt]);
if (!activity) return null;
if (!renderedActivity) return null;
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(/\.+$/, '');
const minutes = Math.floor(elapsedSeconds / 60);
@@ -50,19 +72,31 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
const elapsedLabel = minutes < 1
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}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 (
<div className="animate-in fade-in mb-2 w-full duration-300">
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1">
<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>
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
<div
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 />
<Shimmer className="font-medium">{`${label}`}</Shimmer>
<span className="tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
</div>
{activity.canInterrupt && onAbort && (
{renderedActivity.canInterrupt && onAbort && (
<button
type="button"
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' })}
>
<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,
TouchEvent,
} 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 { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
@@ -68,9 +68,6 @@ interface ChatComposerProps {
onToggleCommandMenu: () => void;
hasInput: boolean;
onClearInput: () => void;
isUserScrolledUp: boolean;
hasMessages: boolean;
onScrollToBottom: () => void;
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
isDragActive: boolean;
attachedImages: File[];
@@ -101,6 +98,7 @@ interface ChatComposerProps {
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
isInputFocused?: boolean;
onInputFocusChange?: (focused: boolean) => void;
placeholder: string;
isTextareaExpanded: boolean;
@@ -122,9 +120,6 @@ export default function ChatComposer({
onToggleCommandMenu,
hasInput,
onClearInput,
isUserScrolledUp,
hasMessages,
onScrollToBottom,
onSubmit,
isDragActive,
attachedImages,
@@ -155,6 +150,7 @@ export default function ChatComposer({
onTextareaPaste,
onTextareaScrollSync,
onTextareaInput,
isInputFocused = false,
onInputFocusChange,
placeholder,
isTextareaExpanded,
@@ -201,15 +197,18 @@ export default function ChatComposer({
// Hide the thinking/status bar while any permission request is pending
const hasPendingPermissions = pendingPermissionRequests.length > 0;
const hasActivityIndicator = Boolean(activity && !hasPendingPermissions);
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 && (
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 w-[calc(100%-1rem)] max-w-3xl -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 && (
<div className="mx-auto mb-3 max-w-4xl">
<div className="mx-auto mb-3 max-w-3xl">
<PermissionRequestsBanner
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
@@ -218,19 +217,7 @@ export default function ChatComposer({
</div>
)}
{!hasQuestionPanel && <div className="relative mx-auto max-w-4xl">
{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>
)}
{!hasQuestionPanel && <div className="relative mx-auto max-w-3xl">
{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">
{filteredFiles.map((file, index) => (
@@ -271,7 +258,10 @@ export default function ChatComposer({
<PromptInput
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
status={isLoading ? 'streaming' : 'ready'}
className={isTextareaExpanded ? 'chat-input-expanded' : ''}
className={[
isTextareaExpanded ? 'chat-input-expanded' : '',
hasActivityIndicator ? 'rounded-t-none' : '',
].filter(Boolean).join(' ')}
{...getRootProps()}
>
{isDragActive && (
@@ -349,7 +339,7 @@ export default function ChatComposer({
<button
type="button"
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'
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
: permissionMode === 'acceptEdits'

View File

@@ -1,5 +1,5 @@
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 { ChatMessage } from '../../types/types';
@@ -15,6 +15,7 @@ import { groupConsecutiveTools, isToolGroupItem } from '../../utils/toolGrouping
import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import ToolGroupContainer from './ToolGroupContainer';
import LoadAllMessagesOverlay from './LoadAllMessagesOverlay';
interface ChatMessagesPaneProps {
scrollContainerRef: RefObject<HTMLDivElement>;
@@ -61,7 +62,6 @@ interface ChatMessagesPaneProps {
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject: Project;
@@ -111,48 +111,59 @@ function ChatMessagesPane({
onFileOpen,
onShowSettings,
onGrantToolPermission,
autoExpandTools,
showRawParameters,
showThinking,
selectedProject,
}: ChatMessagesPaneProps) {
const { t } = useTranslation('chat');
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
const allocatedKeysRef = useRef<Set<string>>(new Set());
const generatedMessageKeyCounterRef = useRef(0);
const groupedVisibleMessages = useMemo(() => groupConsecutiveTools(visibleMessages), [visibleMessages]);
const groupedVisibleMessages = useMemo(
() => groupConsecutiveTools(visibleMessages, Boolean(showThinking)),
[visibleMessages, showThinking],
);
// Keep keys stable across prepends so existing MessageComponent instances retain local state.
const getMessageKey = useCallback((message: ChatMessage) => {
const existingKey = messageKeyMapRef.current.get(message);
if (existingKey) {
return existingKey;
// Stable, deterministic keys for the messages rendered this pass.
//
// `normalizedToChatMessages` rebuilds fresh ChatMessage objects on every store
// update, so caching keys by object identity (or via a cross-render allocation
// Set) minted a brand-new key for the *same* logical message on each prepend —
// remounting the whole list, which disconnects the scroll-restore anchor and
// reflows heights, jumping the viewport to the bottom. Deriving keys purely
// from this render's ordered messages (intrinsic key, disambiguated by
// occurrence index on collision) yields the same key for the same message
// order, so React preserves existing DOM nodes and component state on prepend.
const messageKeyMap = useMemo(() => {
const keys = new WeakMap<ChatMessage, string>();
const occurrences = new Map<string, number>();
const assign = (message: ChatMessage) => {
const intrinsicKey = getIntrinsicMessageKey(message) ?? 'message-generated';
const seen = occurrences.get(intrinsicKey) ?? 0;
occurrences.set(intrinsicKey, seen + 1);
keys.set(message, seen === 0 ? intrinsicKey : `${intrinsicKey}__${seen}`);
};
for (const item of groupedVisibleMessages) {
if (isToolGroupItem(item)) {
item.messages.forEach(assign);
} else {
assign(item);
}
}
return keys;
}, [groupedVisibleMessages]);
const intrinsicKey = getIntrinsicMessageKey(message);
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));
}
allocatedKeysRef.current.add(candidateKey);
messageKeyMapRef.current.set(message, candidateKey);
return candidateKey;
}, []);
const getMessageKey = useCallback(
(message: ChatMessage) =>
messageKeyMap.get(message) ?? getIntrinsicMessageKey(message) ?? 'message-generated',
[messageKeyMap],
);
return (
<div
ref={scrollContainerRef}
onWheel={onWheel}
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-3xl space-y-3 px-4 sm:space-y-4">
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-center space-x-2">
@@ -208,35 +219,13 @@ function ChatMessagesPane({
</div>
)}
{/* Floating "Load all messages" overlay */}
{(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && (
<div className="pointer-events-none sticky top-2 z-20 flex justify-center">
{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={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>
)}
<LoadAllMessagesOverlay
showLoadAllOverlay={showLoadAllOverlay}
isLoadingAllMessages={isLoadingAllMessages}
loadAllJustFinished={loadAllJustFinished}
totalMessages={totalMessages}
onLoadAllMessages={loadAllMessages}
/>
{/* Legacy message count indicator (for non-paginated view) */}
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
@@ -273,7 +262,6 @@ function ChatMessagesPane({
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
@@ -294,7 +282,6 @@ function ChatMessagesPane({
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
@@ -305,6 +292,7 @@ function ChatMessagesPane({
})()}
</>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import type { CSSProperties, ReactElement } from 'react';
import {
CornerDownLeft,
Folder,
@@ -77,6 +78,7 @@ const namespaceAccentClasses: Record<string, string> = {
const MENU_EDGE_GAP = 16;
const MENU_MAX_HEIGHT = 360;
const MENU_MIN_HEIGHT = 160;
const getCommandKey = (command: CommandMenuCommand) =>
`${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') {
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) {
const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
return {
position: 'fixed',
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))`,
};
}
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(
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'];
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(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) {
return (
return renderInPortal(
<div
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={{
...menuBaseStyle,
...menuPosition,
@@ -237,20 +242,20 @@ export default function CommandMenu({
);
}
return (
return renderInPortal(
<div
ref={menuRef}
role="listbox"
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)' }}
>
{orderedNamespaces.map((namespace) => (
<div key={namespace} className="command-group">
{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 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}
</span>
</div>
@@ -268,15 +273,15 @@ export default function CommandMenu({
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 ${
isSelected
? 'border-sky-200 bg-sky-50 shadow-sm dark:border-cyan-400/30 dark:bg-cyan-400/10'
: '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-primary/30 bg-primary/10 shadow-sm'
: 'border-transparent bg-transparent hover:border-border hover:bg-accent'
}`}
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
onMouseDown={(event) => event.preventDefault()}
>
{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}`}>
<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={`flex min-w-0 items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
<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}
>
{command.name}
</span>
{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}
</span>
)}
</div>
{command.description && (
<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}
>
{command.description}
@@ -305,7 +310,7 @@ export default function CommandMenu({
)}
</div>
{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} />
</span>
)}

View File

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

View File

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

View File

@@ -4,11 +4,12 @@ import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
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 { normalizeInlineCodeFences } from '../../utils/chatFormatting';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
import { useTheme } from '../../../../contexts/ThemeContext';
type MarkdownProps = {
children: React.ReactNode;
@@ -59,6 +60,7 @@ type CodeBlockProps = {
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
const { t } = useTranslation('chat');
const { isDarkMode } = useTheme();
const [copied, setCopied] = useState(false);
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
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')}
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
>
@@ -132,17 +134,20 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
<SyntaxHighlighter
language={language}
style={oneDark}
style={isDarkMode ? oneDark : oneLight}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
borderRadius: '0.75rem',
fontSize: '0.875rem',
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={{
style: {
fontFamily:
'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 = {
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 className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
{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 SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
@@ -30,7 +30,6 @@ type MessageComponentProps = {
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject?: Project | null;
@@ -45,7 +44,7 @@ type InteractiveOption = {
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 isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') ||
@@ -53,7 +52,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
(prevMessage.type === 'tool') ||
(prevMessage.type === 'error'));
const messageRef = useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const userCopyContent = String(message.content || '');
const formattedMessageContent = useMemo(
() => formatUsageLimitText(String(message.content || '')),
@@ -72,32 +70,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
!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 shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
@@ -115,7 +87,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
/* 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="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}
</div>
{message.images && message.images.length > 0 && (
@@ -166,7 +138,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
🔧
</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" />
</div>
)}
@@ -194,7 +166,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<>
<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 || '')}
</Markdown>
</div>
@@ -210,7 +182,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
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>
</div>
<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 || '')}
</Markdown>
</div>
@@ -250,7 +221,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
autoExpandTools={autoExpandTools}
/>
</div>
)
@@ -342,7 +312,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<Reasoning defaultOpen={false}>
<ReasoningTrigger />
<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}
</Markdown>
<div className="mt-3 flex items-center text-[11px]">
@@ -377,15 +347,15 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
return (
<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">
<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>
<span className="font-medium">{t('json.response')}</span>
</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">
<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}
</code>
</pre>
@@ -399,7 +369,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
// Normal rendering for non-JSON content
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}
</Markdown>
) : (

View File

@@ -1,4 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { copyTextToClipboard } from '../../../../utils/clipboard';
@@ -49,9 +51,32 @@ const MessageCopyControl = ({
const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);
const [copied, setCopied] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
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);
// 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(
() => [
{
@@ -83,18 +108,28 @@ const MessageCopyControl = ({
}, [defaultFormat]);
useEffect(() => {
// Close the dropdown when clicking anywhere outside this control.
if (!isDropdownOpen) return;
// Close when clicking outside both the control and the portaled menu.
const closeOnOutsideClick = (event: MouseEvent) => {
if (!isDropdownOpen) return;
const target = event.target as Node;
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
setIsDropdownOpen(false);
if (dropdownRef.current?.contains(target) || menuRef.current?.contains(target)) {
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('scroll', closeOnScroll, true);
window.addEventListener('resize', closeOnScroll);
return () => {
window.removeEventListener('mousedown', closeOnOutsideClick);
window.removeEventListener('scroll', closeOnScroll, true);
window.removeEventListener('resize', closeOnScroll);
};
}, [isDropdownOpen]);
@@ -170,8 +205,9 @@ const MessageCopyControl = ({
{canSelectCopyFormat && (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setIsDropdownOpen((prev) => !prev)}
onClick={() => (isDropdownOpen ? setIsDropdownOpen(false) : openDropdown())}
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
@@ -186,8 +222,12 @@ const MessageCopyControl = ({
</svg>
</button>
{isDropdownOpen && (
<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">
{isDropdownOpen && createPortal(
<div
ref={menuRef}
style={menuStyle}
className="min-w-36 rounded-md border border-border bg-popover p-1 shadow-lg"
>
{copyFormatOptions.map((option) => {
const isSelected = option.format === selectedFormat;
return (
@@ -196,15 +236,16 @@ const MessageCopyControl = ({
type="button"
onClick={() => handleFormatChange(option.format)}
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'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60'
? 'bg-accent text-foreground'
: 'text-foreground hover:bg-accent'
}`}
>
<span className="block text-xs font-medium">{option.label}</span>
</button>
);
})}
</div>
</div>,
document.body,
)}
</>
)}

View File

@@ -43,7 +43,7 @@ export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryP
<button
type="button"
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`}
aria-label="Show token usage"
>

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { api } from '../../../utils/api';
import type { CodeEditorFile } from '../types/types';
import { isBinaryFile } from '../utils/binaryFile';
import { getPreviewKind } from '../utils/previewableFile';
type UseCodeEditorDocumentParams = {
file: CodeEditorFile;
@@ -23,6 +24,9 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
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;
// the fallback to `projectPath` preserves older callers that didn't yet
// propagate the identifier.
@@ -38,8 +42,19 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
setLoading(true);
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
if (isBinaryFile(file.name)) {
setContent('');
setIsBinary(true);
setLoading(false);
return;
@@ -76,6 +91,12 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
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);
setSaveError(null);
@@ -109,7 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
} finally {
setSaving(false);
}
}, [content, filePath, fileProjectId]);
}, [content, filePath, fileProjectId, previewKind, fileName]);
const handleDownload = useCallback(() => {
const blob = new Blob([content], { type: 'text/plain' });
@@ -134,6 +155,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
saveSuccess,
saveError,
isBinary,
previewKind,
fileProjectId,
handleSave,
handleDownload,
};

View File

@@ -5,15 +5,6 @@ import {
CODE_EDITOR_STORAGE_KEYS,
} 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 value = localStorage.getItem(storageKey);
if (value === null) {
@@ -33,7 +24,6 @@ const readFontSize = () => {
};
export const useCodeEditorSettings = () => {
const [isDarkMode, setIsDarkMode] = useState(readTheme);
const [wordWrap, setWordWrap] = useState(readWordWrap);
const [minimapEnabled, setMinimapEnabled] = useState(() => (
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
@@ -43,18 +33,13 @@ export const useCodeEditorSettings = () => {
));
const [fontSize, setFontSize] = useState(readFontSize);
// Keep legacy behavior where the editor writes theme and wrap settings directly.
useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
// Keep legacy behavior where the editor writes wrap settings directly.
useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
}, [wordWrap]);
useEffect(() => {
const refreshFromStorage = () => {
setIsDarkMode(readTheme());
setWordWrap(readWordWrap());
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
@@ -71,8 +56,6 @@ export const useCodeEditorSettings = () => {
}, []);
return {
isDarkMode,
setIsDarkMode,
wordWrap,
setWordWrap,
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 { unifiedMergeView } from '@codemirror/merge';
import type { Extension } from '@codemirror/state';
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import { useTheme } from '../../../contexts/ThemeContext';
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
@@ -11,11 +13,13 @@ import type { CodeEditorFile } from '../types/types';
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
import { getEditorStyles } from '../utils/editorStyles';
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview';
type CodeEditorProps = {
file: CodeEditorFile;
@@ -42,8 +46,10 @@ export default function CodeEditor({
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
const [markdownPreview, setMarkdownPreview] = useState(false);
// The code editor follows the app-wide theme; it has no theme of its own.
const { isDarkMode } = useTheme();
const {
isDarkMode,
wordWrap,
minimapEnabled,
showLineNumbers,
@@ -58,6 +64,8 @@ export default function CodeEditor({
saveSuccess,
saveError,
isBinary,
previewKind,
fileProjectId,
handleSave,
handleDownload,
} = useCodeEditorDocument({
@@ -70,6 +78,29 @@ export default function CodeEditor({
return extension === 'md' || extension === 'markdown';
}, [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(
() => (
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
if (isBinary) {
return (
@@ -197,10 +252,12 @@ export default function CodeEditor({
isSidebar={isSidebar}
isFullscreen={isFullscreen}
isMarkdownFile={isMarkdownFile}
isHtmlPreviewFile={isHtmlPreviewFile}
markdownPreview={markdownPreview}
saving={saving}
saveSuccess={saveSuccess}
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
onOpenHtmlPreview={openHtmlPreview}
onOpenSettings={() => paletteOps.openSettings('appearance')}
onDownload={handleDownload}
onSave={handleSave}
@@ -210,6 +267,7 @@ export default function CodeEditor({
showingChanges: t('header.showingChanges'),
editMarkdown: t('actions.editMarkdown'),
previewMarkdown: t('actions.previewMarkdown'),
previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'),
settings: t('toolbar.settings'),
download: t('actions.download'),
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 type { CodeEditorFile } from '../../types/types';
type CodeEditorHeaderProps = {
@@ -6,10 +7,12 @@ type CodeEditorHeaderProps = {
isSidebar: boolean;
isFullscreen: boolean;
isMarkdownFile: boolean;
isHtmlPreviewFile: boolean;
markdownPreview: boolean;
saving: boolean;
saveSuccess: boolean;
onToggleMarkdownPreview: () => void;
onOpenHtmlPreview: () => void;
onOpenSettings: () => void;
onDownload: () => void;
onSave: () => void;
@@ -19,6 +22,7 @@ type CodeEditorHeaderProps = {
showingChanges: string;
editMarkdown: string;
previewMarkdown: string;
previewHtml: string;
settings: string;
download: string;
save: string;
@@ -35,10 +39,12 @@ export default function CodeEditorHeader({
isSidebar,
isFullscreen,
isMarkdownFile,
isHtmlPreviewFile,
markdownPreview,
saving,
saveSuccess,
onToggleMarkdownPreview,
onOpenHtmlPreview,
onOpenSettings,
onDownload,
onSave,
@@ -82,6 +88,17 @@ export default function CodeEditorHeader({
</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
type="button"
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 type { ComponentProps } from 'react';
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 { useTheme } from '../../../../../contexts/ThemeContext';
type MarkdownCodeBlockProps = {
inline?: boolean;
@@ -16,6 +17,7 @@ export default function MarkdownCodeBlock({
node: _node,
...props
}: MarkdownCodeBlockProps) {
const { isDarkMode } = useTheme();
const [copied, setCopied] = useState(false);
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(rawContent);
@@ -50,20 +52,22 @@ export default function MarkdownCodeBlock({
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'}
</button>
<SyntaxHighlighter
language={language}
style={prismOneDark}
style={isDarkMode ? prismOneDark : prismOneLight}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
borderRadius: '0.75rem',
fontSize: '0.875rem',
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
...(isDarkMode ? {} : { background: 'hsl(var(--muted))' }),
}}
codeTagProps={{ style: isDarkMode ? {} : { background: 'transparent' } }}
>
{rawContent}
</SyntaxHighlighter>

View File

@@ -12,6 +12,9 @@ type MarkdownPreviewProps = {
const markdownPreviewComponents: Components = {
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 className="my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
{children}

View File

@@ -189,7 +189,7 @@ export default function GitPanelHeader({
<button
onClick={requestPublishConfirmation}
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}`}
>
<Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} />

View File

@@ -54,7 +54,7 @@ function MainContent({
newSessionTrigger,
}: MainContentProps) {
const { preferences } = useUiPreferences();
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
const { showRawParameters, showThinking, sendByCtrlEnter } = preferences;
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
@@ -170,10 +170,8 @@ function MainContent({
onNavigateToSession={onNavigateToSession}
onSessionEstablished={onSessionEstablished}
onShowSettings={onShowSettings}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
externalMessageUpdate={externalMessageUpdate}
newSessionTrigger={newSessionTrigger}

View File

@@ -70,7 +70,7 @@ export default function MainContentTitle({
<div className="min-w-0 flex-1">
{activeTab === 'chat' && selectedSession ? (
<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)}
</h2>
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>

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_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
claude: 'bg-purple-600 text-white hover:bg-purple-700',
cursor: 'bg-purple-600 text-white hover:bg-purple-700',
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
opencode: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-700 dark:hover:bg-zinc-600',
claude: 'bg-primary text-primary-foreground hover:bg-primary/90',
cursor: 'bg-primary text-primary-foreground hover:bg-primary/90',
codex: 'bg-primary text-primary-foreground hover:bg-primary/90',
gemini: 'bg-primary text-primary-foreground hover:bg-primary/90',
opencode: 'bg-primary text-primary-foreground hover:bg-primary/90',
};
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {

View File

@@ -135,7 +135,7 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer
return (
<div className="space-y-4">
<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>
</div>
<p className="text-sm text-muted-foreground">{description}</p>

View File

@@ -1,4 +1,5 @@
import { FolderOpen, Globe, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../../shared/view/ui';
@@ -119,8 +120,8 @@ export default function McpServerFormModal({
const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider];
const showCodexOnlyFields = provider === 'codex' && !isGlobalMode;
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
return createPortal(
<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="flex items-center justify-between border-b border-border p-4">
<h3 className="text-lg font-medium text-foreground">{modalTitle}</h3>
@@ -418,7 +419,7 @@ export default function McpServerFormModal({
<Button
type="submit"
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
? t('mcpForm.actions.saving')
@@ -429,6 +430,7 @@ export default function McpServerFormModal({
</div>
</form>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -148,11 +148,18 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
return (
<>
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-2xl">
<div className="relative h-screen overflow-y-auto bg-background">
<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} />
<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 ? (
<GitConfigurationStep
gitName={gitName}
@@ -168,13 +175,16 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
/>
)}
{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">
<p className="text-sm text-red-700 dark:text-red-400">{errorMessage}</p>
</div>
)}
{errorMessage && (
<div
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 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
onClick={handlePreviousStep}
disabled={currentStep === 0 || isSubmitting}
@@ -189,7 +199,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
<button
onClick={handleNextStep}
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 ? (
<>
@@ -207,7 +217,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
<button
onClick={handleFinish}
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 ? (
<>
@@ -225,6 +235,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -31,26 +31,26 @@ export default function AgentConnectionCard({
: status.error || 'Not connected';
return (
<div className={`rounded-lg border p-4 transition-colors ${containerClassName}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${iconContainerClassName}`}>
<div className={`rounded-xl border px-3 py-2.5 transition-colors ${containerClassName}`}>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<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" />
</div>
<div>
<div className="flex items-center gap-2 font-medium text-foreground">
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{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 className="text-xs text-muted-foreground">{statusText}</div>
<div className="truncate text-xs text-muted-foreground" title={statusText}>{statusText}</div>
</div>
</div>
{!status.authenticated && !status.loading && (
<button
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
</button>

View File

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

View File

@@ -16,13 +16,13 @@ export default function GitConfigurationStep({
onGitEmailChange,
}: GitConfigurationStepProps) {
return (
<div className="space-y-6">
<div className="mb-8 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">
<GitBranch className="h-8 w-8 text-blue-600 dark:text-blue-400" />
<div className="space-y-5">
<div className="text-center">
<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-7 w-7 text-primary" />
</div>
<h2 className="mb-2 text-2xl font-bold text-foreground">Git Configuration</h2>
<p className="text-muted-foreground">
<h2 className="font-serif text-xl font-bold tracking-tight text-foreground">Git Configuration</h2>
<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.
</p>
</div>
@@ -38,7 +38,7 @@ export default function GitConfigurationStep({
id="gitName"
value={gitName}
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"
required
disabled={isSubmitting}
@@ -56,7 +56,7 @@ export default function GitConfigurationStep({
id="gitEmail"
value={gitEmail}
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"
required
disabled={isSubmitting}

View File

@@ -11,7 +11,7 @@ const onboardingSteps = [
export default function OnboardingStepProgress({ currentStep }: OnboardingStepProgressProps) {
return (
<div className="mb-8">
<div className="mb-5">
<div className="flex items-center justify-between">
{onboardingSteps.map((step, index) => {
const isCompleted = index < currentStep;
@@ -22,18 +22,18 @@ export default function OnboardingStepProgress({ currentStep }: OnboardingStepPr
<div key={step.title} className="contents">
<div className="flex flex-1 flex-col items-center">
<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
? 'border-green-500 bg-green-500 text-white'
? 'border-emerald-500 bg-emerald-500 text-white shadow-lg shadow-emerald-500/25'
: isActive
? 'border-blue-600 bg-blue-600 text-white'
: 'border-border bg-background text-muted-foreground'
? 'border-primary bg-primary text-primary-foreground shadow-lg shadow-primary/25'
: '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 className="mt-2 text-center">
<div className="mt-1.5 text-center">
<p className={`text-sm font-medium ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}>
{step.title}
</p>
@@ -42,7 +42,7 @@ export default function OnboardingStepProgress({ currentStep }: OnboardingStepPr
</div>
{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>
);

View File

@@ -1,11 +1,10 @@
import {
ArrowDown,
Brain,
Eye,
Languages,
Maximize2,
Mic,
} from 'lucide-react';
import type { PreferenceToggleItem } from './types';
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 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`;
@@ -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';
export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [
{
key: 'autoExpandTools',
labelKey: 'quickSettings.autoExpandTools',
icon: Maximize2,
},
{
key: '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[] = [
{
key: 'sendByCtrlEnter',

View File

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

View File

@@ -1,18 +1,19 @@
import { Moon, Sun } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { DarkModeToggle } from '../../../shared/view/ui';
import LanguageSelector from '../../../shared/view/ui/LanguageSelector';
import {
INPUT_SETTING_TOGGLES,
SETTING_ROW_CLASS,
TOOL_DISPLAY_TOGGLES,
VIEW_OPTION_TOGGLES,
} from '../constants';
import type {
PreferenceToggleItem,
PreferenceToggleKey,
QuickSettingsPreferences,
} from '../types';
import QuickSettingsSection from './QuickSettingsSection';
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">
<QuickSettingsSection title={t('quickSettings.sections.appearance')}>
<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 ? (
<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')}
</span>
@@ -65,13 +66,9 @@ export default function QuickSettingsContent({
{renderToggleRows(TOOL_DISPLAY_TOGGLES)}
</QuickSettingsSection>
<QuickSettingsSection title={t('quickSettings.sections.viewOptions')}>
{renderToggleRows(VIEW_OPTION_TOGGLES)}
</QuickSettingsSection>
<QuickSettingsSection title={t('quickSettings.sections.inputSettings')}>
{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')}
</p>
</QuickSettingsSection>

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ export default function QuickSettingsSection({
}: QuickSettingsSectionProps) {
return (
<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}
</h4>
{children}

View File

@@ -17,8 +17,8 @@ function QuickSettingsToggleRow({
}: QuickSettingsToggleRowProps) {
return (
<label className={TOGGLE_ROW_CLASS}>
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Icon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
<span className="flex items-center gap-2 text-sm text-foreground">
<Icon className="h-4 w-4 text-muted-foreground" />
{label}
</span>
<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_SAVE_STATUS = null;
export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
theme: 'dark',
wordWrap: false,
showMinimap: true,
lineNumbers: true,

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ type AppearanceSettingsTabProps = {
projectSortOrder: ProjectSortOrder;
onProjectSortOrderChange: (value: ProjectSortOrder) => void;
codeEditorSettings: CodeEditorSettingsState;
onCodeEditorThemeChange: (value: 'dark' | 'light') => void;
onCodeEditorWordWrapChange: (value: boolean) => void;
onCodeEditorShowMinimapChange: (value: boolean) => void;
onCodeEditorLineNumbersChange: (value: boolean) => void;
@@ -22,7 +21,6 @@ export default function AppearanceSettingsTab({
projectSortOrder,
onProjectSortOrderChange,
codeEditorSettings,
onCodeEditorThemeChange,
onCodeEditorWordWrapChange,
onCodeEditorShowMinimapChange,
onCodeEditorLineNumbersChange,
@@ -69,17 +67,6 @@ export default function AppearanceSettingsTab({
<SettingsSection title={t('appearanceSettings.codeEditor.title')}>
<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
label={t('appearanceSettings.codeEditor.wordWrap.label')}
description={t('appearanceSettings.codeEditor.wordWrap.description')}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -59,12 +59,8 @@ export default function Shell({
isConnected,
isInitialized,
isConnecting,
authUrl,
authUrlVersion,
connectToShell,
disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
} = useShellRuntime({
selectedProject,
selectedSession,
@@ -243,15 +239,7 @@ export default function Shell({
if (minimal) {
return (
<>
<ShellMinimalView
terminalContainerRef={terminalContainerRef}
authUrl={authUrl}
authUrlVersion={authUrlVersion}
initialCommand={initialCommand}
isConnected={isConnected}
openAuthUrlInBrowser={openAuthUrlInBrowser}
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
/>
<ShellMinimalView terminalContainerRef={terminalContainerRef} />
<TerminalShortcutsPanel
wsRef={wsRef}
terminalRef={terminalRef}
@@ -322,7 +310,7 @@ export default function Shell({
{cliPromptOptions && isConnected && (
<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()}
>
<div className="flex flex-wrap items-center gap-2">

View File

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

View File

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

View File

@@ -41,7 +41,7 @@ export const ThemeProvider = ({ children }) => {
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
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 {
document.documentElement.classList.remove('dark');
@@ -55,7 +55,7 @@ export const ThemeProvider = ({ children }) => {
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute('content', '#ffffff'); // Light background color
themeColorMeta.setAttribute('content', '#f6f4ef'); // Light background color (warm cream)
}
}
}, [isDarkMode]);

View File

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

View File

@@ -54,14 +54,11 @@
"sections": {
"appearance": "Darstellung",
"toolDisplay": "Werkzeuganzeige",
"viewOptions": "Anzeigeoptionen",
"inputSettings": "Eingabeeinstellungen"
},
"darkMode": "Darkmode",
"autoExpandTools": "Werkzeuge automatisch erweitern",
"showRawParameters": "Rohe Parameter anzeigen",
"showThinking": "Denken anzeigen",
"autoScrollToBottom": "Automatisch nach unten scrollen",
"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.",
"dragHandle": {

View File

@@ -32,5 +32,10 @@
"binaryFile": {
"title": "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": {
"appearance": "Appearance",
"toolDisplay": "Tool Display",
"viewOptions": "View Options",
"inputSettings": "Input Settings"
},
"darkMode": "Dark Mode",
"autoExpandTools": "Auto-expand tools",
"showRawParameters": "Show raw parameters",
"showThinking": "Show thinking",
"autoScrollToBottom": "Auto-scroll to bottom",
"sendByCtrlEnter": "Send by Ctrl+Enter",
"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.",

View File

@@ -54,14 +54,11 @@
"sections": {
"appearance": "Apparence",
"toolDisplay": "Affichage des outils",
"viewOptions": "Options d'affichage",
"inputSettings": "Paramètres de saisie"
},
"darkMode": "Mode sombre",
"autoExpandTools": "Développer automatiquement les outils",
"showRawParameters": "Afficher les paramètres bruts",
"showThinking": "Afficher la réflexion",
"autoScrollToBottom": "Défilement automatique vers le bas",
"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.",
"dragHandle": {

View File

@@ -54,14 +54,11 @@
"sections": {
"appearance": "Aspetto",
"toolDisplay": "Visualizzazione strumenti",
"viewOptions": "Opzioni visualizzazione",
"inputSettings": "Impostazioni input"
},
"darkMode": "Modalità scura",
"autoExpandTools": "Espandi strumenti automaticamente",
"showRawParameters": "Mostra parametri grezzi",
"showThinking": "Mostra ragionamento",
"autoScrollToBottom": "Scorrimento automatico in basso",
"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.",
"dragHandle": {

View File

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

View File

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

View File

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

View File

@@ -54,14 +54,11 @@
"sections": {
"appearance": "Görünüm",
"toolDisplay": "Araç Gösterimi",
"viewOptions": "Görünüm Seçenekleri",
"inputSettings": "Girdi Ayarları"
},
"darkMode": "Koyu Mod",
"autoExpandTools": "Araçları otomatik genişlet",
"showRawParameters": "Ham parametreleri göster",
"showThinking": "Düşünmeyi göster",
"autoScrollToBottom": "Otomatik en alta kaydır",
"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.",
"dragHandle": {

View File

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

View File

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

View File

@@ -23,37 +23,37 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--background: 44 22% 96%;
--foreground: 36 25% 4%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 36 25% 4%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--popover-foreground: 36 25% 4%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--secondary: 44 15% 91%;
--secondary-foreground: 36 15% 18%;
--muted: 44 15% 91%;
--muted-foreground: 40 5% 44%;
--accent: 44 15% 91%;
--accent-foreground: 36 15% 18%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--border: 44 14% 87%;
--input: 44 14% 87%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
/* 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-saturate: 1.8;
--nav-tab-glow: 221.2 83.2% 53.3% / 0.18;
--nav-tab-ring: 221.2 83.2% 53.3% / 0.10;
--nav-float-shadow: 0 0% 0% / 0.06;
--nav-float-ring: 214.3 31.8% 91.4% / 0.5;
--nav-divider-color: 214.3 31.8% 91.4% / 0.5;
--nav-input-bg: 210 40% 96.1% / 0.5;
--nav-float-ring: 44 14% 87% / 0.5;
--nav-divider-color: 44 14% 87% / 0.5;
--nav-input-bg: 44 15% 91% / 0.5;
--nav-input-focus-ring: 221.2 83.2% 53.3% / 0.22;
/* Safe area CSS variables */
@@ -85,36 +85,36 @@
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 217.2 91.2% 8%;
--card-foreground: 210 40% 98%;
--popover: 217.2 91.2% 8%;
--popover-foreground: 210 40% 98%;
--background: 0 0% 8%;
--foreground: 40 8% 93%;
--card: 0 0% 12%;
--card-foreground: 40 8% 93%;
--popover: 0 0% 12%;
--popover-foreground: 40 8% 93%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--primary-foreground: 0 0% 8%;
--secondary: 0 0% 17%;
--secondary-foreground: 40 8% 93%;
--muted: 0 0% 17%;
--muted-foreground: 0 0% 60%;
--accent: 0 0% 17%;
--accent-foreground: 40 8% 93%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 220 13% 46%;
--destructive-foreground: 40 8% 93%;
--border: 0 0% 17%;
--input: 0 0% 23%;
--ring: 217.2 91.2% 59.8%;
/* 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-saturate: 1.6;
--nav-tab-glow: 217.2 91.2% 59.8% / 0.25;
--nav-tab-ring: 217.2 91.2% 59.8% / 0.15;
--nav-float-shadow: 0 0% 0% / 0.35;
--nav-float-ring: 217.2 32.6% 17.5% / 0.3;
--nav-divider-color: 217.2 32.6% 17.5% / 0.5;
--nav-input-bg: 217.2 32.6% 17.5% / 0.5;
--nav-float-ring: 0 0% 17% / 0.3;
--nav-divider-color: 0 0% 17% / 0.5;
--nav-input-bg: 0 0% 17% / 0.5;
--nav-input-focus-ring: 217.2 91.2% 59.8% / 0.25;
}
}
@@ -128,7 +128,8 @@
body {
@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;
-moz-osx-font-smoothing: grayscale;
margin: 0;
@@ -139,6 +140,12 @@
height: 100%;
margin: 0;
padding: 0;
/* The app shell is a fixed inset-0 container (see AppContent), so the
document itself never needs to scroll. Clipping it removes the phantom
full-height page scrollbar and disables the browser pull-to-refresh
gesture that reloads the page when scrolling up on mobile. */
overflow: hidden;
overscroll-behavior-y: contain;
}
/* Root element with safe area padding for PWA */
@@ -344,7 +351,7 @@
}
.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 {
@@ -363,7 +370,7 @@
}
.dark::-webkit-scrollbar-track {
background: rgba(31, 41, 55, 0.5);
background: rgba(38, 38, 38, 0.5);
}
.dark::-webkit-scrollbar-thumb {
@@ -378,7 +385,7 @@
/* Firefox scrollbar styles */
.dark {
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 */
@@ -469,7 +476,7 @@
/* Fix focus-within container issues in dark mode */
.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 */
@@ -562,7 +569,23 @@
}
.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 {
@@ -867,12 +890,12 @@
/* Fix focus ring offset color in dark mode */
.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 */
.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 */
@@ -915,8 +938,8 @@
}
.dark select option {
background-color: rgb(31 41 55) !important;
color: rgb(243 244 246) !important;
background-color: rgb(31 31 31) !important;
color: rgb(237 235 230) !important;
}
/* Tool details chevron animation */
@@ -941,6 +964,37 @@
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-highlight-flash {
animation: search-flash 4s ease-out;

View File

@@ -14,6 +14,10 @@ export default {
},
},
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: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",