mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-01 01:23:06 +08:00
Compare commits
20 Commits
cloudcli-l
...
feat/desig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
770404c701 | ||
|
|
858bed609a | ||
|
|
1a7f0291b2 | ||
|
|
7d5bd753d4 | ||
|
|
eed37b51d4 | ||
|
|
5798246135 | ||
|
|
a7b455aeac | ||
|
|
c420c6d63e | ||
|
|
54a062baa6 | ||
|
|
9090e73478 | ||
|
|
032258b260 | ||
|
|
e71f3bf3f6 | ||
|
|
19b59e701e | ||
|
|
37ef891945 | ||
|
|
f363127427 | ||
|
|
dc1580dae7 | ||
|
|
4c6e9178f6 | ||
|
|
a9e24e7071 | ||
|
|
2cd1200081 | ||
|
|
b6cf33308d |
12
index.html
12
index.html
@@ -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" />
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
type AuthErrorAlertProps = {
|
||||
errorMessage: string;
|
||||
};
|
||||
@@ -8,8 +10,9 @@ 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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,49 @@ 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}
|
||||
tabIndex={-1}
|
||||
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 disabled:opacity-60"
|
||||
>
|
||||
{isPasswordVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
<h1 className="mb-4 font-serif text-2xl font-bold tracking-tight text-foreground">CloudCLI</h1>
|
||||
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,26 @@ 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}
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatComposer
|
||||
pendingPermissionRequests={pendingPermissionRequests}
|
||||
handlePermissionDecision={handlePermissionDecision}
|
||||
handleGrantToolPermission={handleGrantToolPermission}
|
||||
@@ -377,9 +388,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 +422,7 @@ function ChatInterface({
|
||||
onTextareaPaste={handlePaste}
|
||||
onTextareaScrollSync={syncInputOverlayScroll}
|
||||
onTextareaInput={handleTextareaInput}
|
||||
isInputFocused={isInputFocused}
|
||||
onInputFocusChange={handleInputFocusChange}
|
||||
placeholder={t('input.placeholder', {
|
||||
provider:
|
||||
@@ -430,6 +439,7 @@ function ChatInterface({
|
||||
isTextareaExpanded={isTextareaExpanded}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QuickSettingsPanel />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Extension } from '@codemirror/state';
|
||||
import { 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';
|
||||
@@ -42,8 +43,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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' : ''}`} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -169,12 +176,12 @@ 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 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 +196,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 +214,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 +232,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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">{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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,10 +2,8 @@ import type { CSSProperties } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type PreferenceToggleKey =
|
||||
| 'autoExpandTools'
|
||||
| 'showRawParameters'
|
||||
| 'showThinking'
|
||||
| 'autoScrollToBottom'
|
||||
| 'sendByCtrlEnter'
|
||||
| 'voiceEnabled';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -47,7 +47,6 @@ export type CursorPermissionsState = {
|
||||
};
|
||||
|
||||
export type CodeEditorSettingsState = {
|
||||
theme: 'dark' | 'light';
|
||||
wordWrap: boolean;
|
||||
showMinimap: boolean;
|
||||
lineNumbers: boolean;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
1068
src/components/shell/utils/mobileTerminalSelection.ts
Normal file
1068
src/components/shell/utils/mobileTerminalSelection.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -54,14 +54,11 @@
|
||||
"sections": {
|
||||
"appearance": "外観",
|
||||
"toolDisplay": "ツール表示",
|
||||
"viewOptions": "表示オプション",
|
||||
"inputSettings": "入力設定"
|
||||
},
|
||||
"darkMode": "ダークモード",
|
||||
"autoExpandTools": "ツールを自動展開",
|
||||
"showRawParameters": "生パラメータを表示",
|
||||
"showThinking": "思考を表示",
|
||||
"autoScrollToBottom": "自動スクロール",
|
||||
"sendByCtrlEnter": "Ctrl+Enterで送信",
|
||||
"sendByCtrlEnterDescription": "有効にすると、Enterではなく Ctrl+Enter でメッセージを送信します。IMEユーザーの誤送信防止に便利です。",
|
||||
"dragHandle": {
|
||||
|
||||
@@ -54,14 +54,11 @@
|
||||
"sections": {
|
||||
"appearance": "외관",
|
||||
"toolDisplay": "도구 표시",
|
||||
"viewOptions": "보기 옵션",
|
||||
"inputSettings": "입력 설정"
|
||||
},
|
||||
"darkMode": "다크 모드",
|
||||
"autoExpandTools": "도구 자동 펼치기",
|
||||
"showRawParameters": "Raw 파라미터 표시",
|
||||
"showThinking": "생각 과정 표시",
|
||||
"autoScrollToBottom": "자동 스크롤",
|
||||
"sendByCtrlEnter": "Ctrl+Enter로 전송",
|
||||
"sendByCtrlEnterDescription": "활성화하면 Enter 대신 Ctrl+Enter로 메시지를 전송합니다. IME 사용자가 실수로 전송하는 것을 방지하는 데 유용합니다.",
|
||||
"dragHandle": {
|
||||
|
||||
@@ -54,14 +54,11 @@
|
||||
"sections": {
|
||||
"appearance": "Внешний вид",
|
||||
"toolDisplay": "Отображение инструментов",
|
||||
"viewOptions": "Параметры просмотра",
|
||||
"inputSettings": "Настройки ввода"
|
||||
},
|
||||
"darkMode": "Темная тема",
|
||||
"autoExpandTools": "Автоматически разворачивать инструменты",
|
||||
"showRawParameters": "Показывать сырые параметры",
|
||||
"showThinking": "Показывать размышления",
|
||||
"autoScrollToBottom": "Автопрокрутка вниз",
|
||||
"sendByCtrlEnter": "Отправка по Ctrl+Enter",
|
||||
"sendByCtrlEnterDescription": "Когда включено, нажатие Ctrl+Enter будет отправлять сообщение вместо просто Enter. Это полезно для пользователей IME, чтобы избежать случайной отправки.",
|
||||
"dragHandle": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -54,14 +54,11 @@
|
||||
"sections": {
|
||||
"appearance": "外观",
|
||||
"toolDisplay": "工具显示",
|
||||
"viewOptions": "视图选项",
|
||||
"inputSettings": "输入设置"
|
||||
},
|
||||
"darkMode": "深色模式",
|
||||
"autoExpandTools": "自动展开工具",
|
||||
"showRawParameters": "显示原始参数",
|
||||
"showThinking": "显示思考过程",
|
||||
"autoScrollToBottom": "自动滚动到底部",
|
||||
"sendByCtrlEnter": "使用 Ctrl+Enter 发送",
|
||||
"sendByCtrlEnterDescription": "启用后,按 Ctrl+Enter 发送消息,而不是仅按 Enter。这对于使用输入法的用户可以避免意外发送。",
|
||||
"dragHandle": {
|
||||
|
||||
@@ -54,14 +54,11 @@
|
||||
"sections": {
|
||||
"appearance": "外觀",
|
||||
"toolDisplay": "工具顯示",
|
||||
"viewOptions": "檢視選項",
|
||||
"inputSettings": "輸入設定"
|
||||
},
|
||||
"darkMode": "深色模式",
|
||||
"autoExpandTools": "自動展開工具",
|
||||
"showRawParameters": "顯示原始參數",
|
||||
"showThinking": "顯示思考過程",
|
||||
"autoScrollToBottom": "自動捲動到底部",
|
||||
"sendByCtrlEnter": "使用 Ctrl+Enter 傳送",
|
||||
"sendByCtrlEnterDescription": "啟用後,按 Ctrl+Enter 傳送訊息,而不是僅按 Enter。這對於使用輸入法的使用者可以避免意外傳送。",
|
||||
"dragHandle": {
|
||||
|
||||
147
src/index.css
147
src/index.css
@@ -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,7 @@
|
||||
|
||||
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 +139,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 +350,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 +369,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 +384,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 +475,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 +568,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 +889,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 +937,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 +963,37 @@
|
||||
animation: settings-fade-in 150ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes chat-activity-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(3px);
|
||||
transform: translateY(18px) scaleY(0.92);
|
||||
}
|
||||
65% {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(-2px) scaleY(1.01);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0) scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes chat-activity-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0) scaleY(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
filter: blur(2px);
|
||||
transform: translateY(14px) scaleY(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
/* Search result highlight flash */
|
||||
.search-highlight-flash {
|
||||
animation: search-flash 4s ease-out;
|
||||
|
||||
@@ -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))",
|
||||
|
||||
Reference in New Issue
Block a user