diff --git a/.opencode/skills/theme-system/SKILL.md b/.opencode/skills/theme-system/SKILL.md index 9304a6ec..8fb7ecec 100644 --- a/.opencode/skills/theme-system/SKILL.md +++ b/.opencode/skills/theme-system/SKILL.md @@ -33,6 +33,43 @@ OpenChamber uses a JSON-based theme system. Themes are defined in `packages/ui/s - Status colors = **ONLY for actual feedback** (errors, warnings, success) - Input footers = `bg-transparent` on elevated background +## Button Rules (MANDATORY) + +Use only the shared `Button` component from `packages/ui/src/components/ui/button.tsx`. + +- Do not create wrapper button components (for example `ButtonLarge`, `ButtonSmall`). +- Do not hardcode button height/padding classes when a `size` variant exists. +- Use semantic button variants consistently; avoid ad-hoc one-off button styling. + +### Allowed Button Variants + +| Variant | Use for | Token direction | +|-------|-------|-------| +| `default` | Primary action in a local section/dialog | `primary.*` | +| `outline` | Secondary visible action | `surface.elevated` + `interactive.*` | +| `secondary` | Soft secondary action | `interactive.hover` / `interactive.active` | +| `ghost` | Low-emphasis row/toolbar action | transparent + `interactive.hover` | +| `destructive` | Destructive actions (`Delete`, `Revert all`) | `status.error*` | +| `link` | Rare inline text action only | text-link style | + +### Allowed Button Sizes + +| Size | Use for | +|------|---------| +| `xs` | Dense controls in rows/lists | +| `sm` | Default compact action buttons | +| `default` | Standard form/page actions | +| `lg` | Prominent large actions | +| `icon` | Icon-only square button | + +### Button Selection Quick Guide + +1. Main CTA in section/dialog -> `default` +2. Side action next to CTA -> `outline` +3. Quiet auxiliary action -> `ghost` +4. Dangerous action -> `destructive` +5. Tiny row action -> keep same variant, set `size="xs"` + ### Never Use - Hardcoded hex colors (`#FF0000`) diff --git a/AGENTS.md b/AGENTS.md index 53c09084..c601cacb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,7 @@ All scripts are in `package.json`. - React: prefer function components + hooks; class only when needed (e.g. error boundaries). - Control flow: avoid nested ternaries; prefer early returns + `if/else`/`switch`. - Styling: Tailwind v4; typography via `packages/ui/src/lib/typography.ts`; theme vars via `packages/ui/src/lib/theme/`. +- Shared UI patterns: for "series of items + divider + series of items" layouts, use shared UI primitives instead of duplicating ad-hoc markup in feature components. - Toasts: use custom toast wrapper from `@/components/ui` (backed by `packages/ui/src/components/ui/toast.ts`); do not import `sonner` directly in feature code. - No new deps unless asked. - Never add secrets (`.env`, keys) or log sensitive data. diff --git a/bun.lock b/bun.lock index e8e349c0..93c0d94e 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.2.26", + "@opencode-ai/sdk": "^1.2.27", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -98,7 +98,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.8.5", + "version": "1.8.7", "devDependencies": { "@tauri-apps/cli": "^2", "@types/node": "^24.3.1", @@ -107,7 +107,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.8.5", + "version": "1.8.7", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", @@ -137,7 +137,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.2.26", + "@opencode-ai/sdk": "^1.2.27", "@pierre/diffs": "1.1.0-beta.13", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -211,10 +211,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.8.5", + "version": "1.8.7", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.2.26", + "@opencode-ai/sdk": "^1.2.27", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -234,7 +234,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.8.5", + "version": "1.8.7", "bin": { "openchamber": "./bin/cli.js", }, @@ -246,7 +246,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.2.26", + "@opencode-ai/sdk": "^1.2.27", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -841,7 +841,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.20", "", {}, "sha512-U5ROpG21D8jg9rkc1IgKAk1g5dn6X/rkOBfveupd0peSDO9n6VM9aikYccVLaMObxVqdjtG08IeQOFTPVS8ySQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.27", "", {}, "sha512-Wk0o/I+Fo+wE3zgvlJDs8Fb67KlKqX0PrV8dK5adSDkANq6r4Z25zXJg2iOir+a8ntg3rAcpel1OY4FV/TwRUA=="], "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="], diff --git a/docs/CUSTOM_THEMES.md b/docs/CUSTOM_THEMES.md index db04adec..fbaf1c2f 100644 --- a/docs/CUSTOM_THEMES.md +++ b/docs/CUSTOM_THEMES.md @@ -45,9 +45,9 @@ OpenChamber supports user-defined themes. Drop a JSON file into the themes direc "surface": { "background": "#100F0F", "foreground": "#CECDC3", - "muted": "#1C1B1A", + "muted": "#1C1B1A90", "mutedForeground": "#878580", - "elevated": "#1C1A19", + "elevated": "#1C1A1990", "elevatedForeground": "#CECDC3", "overlay": "#00000080", "subtle": "#1e1d1c" @@ -194,10 +194,10 @@ OpenChamber supports user-defined themes. Drop a JSON file into the themes direc }, "radius": { "none": "0", - "sm": "0.125rem", - "md": "0.375rem", - "lg": "0.5rem", - "xl": "0.75rem", + "sm": "0.325rem", + "md": "0.75rem", + "lg": "1.125rem", + "xl": "1.5rem", "full": "9999px" }, "transitions": { @@ -209,6 +209,10 @@ OpenChamber supports user-defined themes. Drop a JSON file into the themes direc } ``` +## Surface Alpha Requirement + +- `colors.surface.muted` and `colors.surface.elevated` must always use 90 alpha (`...90` in 8-digit hex, e.g. `#1C1B1A90`). + ## Validation Themes are validated on load. Invalid themes are skipped with a console warning. diff --git a/docs/references/chat_example.png b/docs/references/chat_example.png index 20b22a10..6937f8f1 100644 Binary files a/docs/references/chat_example.png and b/docs/references/chat_example.png differ diff --git a/docs/references/diff_example.png b/docs/references/diff_example.png index e3d0eac4..38a1fc10 100644 Binary files a/docs/references/diff_example.png and b/docs/references/diff_example.png differ diff --git a/docs/references/tool_output_example.png b/docs/references/tool_output_example.png index fb93131b..20e2464b 100644 Binary files a/docs/references/tool_output_example.png and b/docs/references/tool_output_example.png differ diff --git a/docs/references/web_version_example.png b/docs/references/web_version_example.png index c4307e87..28b2ef59 100644 Binary files a/docs/references/web_version_example.png and b/docs/references/web_version_example.png differ diff --git a/package.json b/package.json index 1d30f652..5e45e354 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.2.26", + "@opencode-ai/sdk": "^1.2.27", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 3410b2c2..d8390d2c 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2687,6 +2687,7 @@ dependencies = [ "tauri-plugin-updater", "tokio", "url", + "window-vibrancy 0.7.1", ] [[package]] @@ -4269,7 +4270,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "window-vibrancy", + "window-vibrancy 0.6.0", "windows", ] @@ -5461,6 +5462,21 @@ dependencies = [ "windows-version", ] +[[package]] +name = "window-vibrancy" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010797bd7c40396fbc59d3105089fed0885fe267a0ef4a0a4646df54e28647f6" +dependencies = [ + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "windows-sys 0.60.2", + "windows-version", +] + [[package]] name = "windows" version = "0.61.3" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index aafc1163..53833aad 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -30,3 +30,6 @@ url = "2.5" [build-dependencies] tauri-build = { version = "2.5.3", features = [] } + +[target.'cfg(target_os = "macos")'.dependencies] +window-vibrancy = "0.7.1" diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 9c1a9eb0..db3edd60 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -24,6 +24,10 @@ use std::{ }; use tauri::utils::config::BackgroundThrottlingPolicy; use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; +#[cfg(target_os = "macos")] +use window_vibrancy::{ + apply_vibrancy, clear_vibrancy, NSVisualEffectMaterial, +}; /// Global counter for generating unique window labels. static WINDOW_COUNTER: AtomicU64 = AtomicU64::new(1); @@ -2358,6 +2362,78 @@ fn build_init_script(local_origin: &str) -> String { init_script } +fn parse_theme_override(theme_mode: Option<&str>, theme_variant: Option<&str>) -> Option { + match theme_mode.map(str::trim) { + Some("system") => None, + Some("dark") => Some(tauri::Theme::Dark), + Some("light") => Some(tauri::Theme::Light), + _ => match theme_variant.map(str::trim) { + Some("dark") => Some(tauri::Theme::Dark), + Some("light") => Some(tauri::Theme::Light), + _ => None, + }, + } +} + +fn read_desktop_theme_override() -> Option { + let settings = fs::read_to_string(settings_file_path()) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()); + + let use_system_theme = settings + .as_ref() + .and_then(|value| value.get("useSystemTheme")) + .and_then(|value| value.as_bool()); + + if matches!(use_system_theme, Some(true)) { + return None; + } + + let theme_mode = settings + .as_ref() + .and_then(|value| value.get("themeMode")) + .and_then(|value| value.as_str()); + + let theme_variant = settings + .as_ref() + .and_then(|value| value.get("themeVariant")) + .and_then(|value| value.as_str()); + + parse_theme_override(theme_mode, theme_variant) +} + +#[cfg(target_os = "macos")] +fn apply_macos_window_vibrancy(window: &tauri::WebviewWindow) { + let _ = clear_vibrancy(window); + + if let Err(error) = apply_vibrancy( + window, + NSVisualEffectMaterial::Sidebar, + None, + None, + ) { + log::warn!("[desktop:vibrancy] Failed to apply macOS vibrancy: {error}"); + } +} + +#[cfg(not(target_os = "macos"))] +fn apply_macos_window_vibrancy(_window: &tauri::WebviewWindow) {} + +#[tauri::command] +fn desktop_set_window_theme( + window: tauri::WebviewWindow, + theme_mode: Option, + theme_variant: Option, +) -> Result<(), String> { + let override_theme = parse_theme_override(theme_mode.as_deref(), theme_variant.as_deref()); + + window + .set_theme(override_theme) + .map_err(|error| format!("failed to set window theme: {error}"))?; + + Ok(()) +} + fn is_window_state_visible(app: &tauri::AppHandle, state: &DesktopWindowState) -> bool { if state.width == 0 || state.height == 0 { return false; @@ -2523,6 +2599,7 @@ fn create_window( #[cfg(target_os = "macos")] { builder = builder + .transparent(true) .hidden_title(true) .title_bar_style(tauri::TitleBarStyle::Overlay) .traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition { @@ -2532,6 +2609,8 @@ fn create_window( } let window = builder.build()?; + let _ = window.set_theme(read_desktop_theme_override()); + apply_macos_window_vibrancy(&window); if let Some(state) = restored_state.as_ref().filter(|_| apply_restored_state) { if state.maximized || state.fullscreen { @@ -2583,6 +2662,7 @@ fn create_startup_window(app: &tauri::AppHandle, restore_geometry: bool) -> Resu #[cfg(target_os = "macos")] { builder = builder + .transparent(true) .hidden_title(true) .title_bar_style(tauri::TitleBarStyle::Overlay) .traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition { @@ -2592,6 +2672,8 @@ fn create_startup_window(app: &tauri::AppHandle, restore_geometry: bool) -> Resu } let window = builder.build()?; + let _ = window.set_theme(read_desktop_theme_override()); + apply_macos_window_vibrancy(&window); if let Some(state) = restored_state.as_ref().filter(|_| apply_restored_state) { if state.maximized || state.fullscreen { @@ -3025,6 +3107,7 @@ fn main() { desktop_hosts_get, desktop_hosts_set, desktop_host_probe, + desktop_set_window_theme, remote_ssh::desktop_ssh_instances_get, remote_ssh::desktop_ssh_instances_set, remote_ssh::desktop_ssh_import_hosts, diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index ba5187e0..b80aad79 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -15,7 +15,7 @@ "label": "main", "create": false, "title": "OpenChamber", - "transparent": false, + "transparent": true, "width": 1280, "height": 800, "resizable": true, diff --git a/packages/ui/package.json b/packages/ui/package.json index 944f5a67..9cf53a5b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,7 +39,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.2.26", + "@opencode-ai/sdk": "^1.2.27", "@pierre/diffs": "1.1.0-beta.13", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 2e349a40..13875df0 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -113,6 +113,7 @@ function App({ apis }: AppProps) { const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState(() => apis.runtime.isVSCode); const [showCliOnboarding, setShowCliOnboarding] = React.useState(false); const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true); + const isDesktopRuntime = React.useMemo(() => isDesktopShell(), []); const appReadyDispatchedRef = React.useRef(false); const embeddedSessionChat = React.useMemo(() => readEmbeddedSessionChatConfig(), []); const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible; @@ -534,7 +535,7 @@ function App({ apis }: AppProps) { -
+
diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index e713ae7f..36a5c995 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -15,7 +15,7 @@ import { useChatScrollManager } from '@/hooks/useChatScrollManager'; import { useChatTimelineController } from './hooks/useChatTimelineController'; import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; import { useDeviceInfo } from '@/lib/device'; -import { ButtonSmall } from '@/components/ui/button-small'; +import { Button } from '@/components/ui/button'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; import { TimelineDialog } from './TimelineDialog'; import type { PermissionRequest } from '@/types/permission'; @@ -30,6 +30,7 @@ const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; const EMPTY_PERMISSIONS: PermissionRequest[] = []; const EMPTY_QUESTIONS: QuestionRequest[] = []; const IDLE_SESSION_STATUS = { type: 'idle' as const }; +const SESSION_RESELECTED_EVENT = 'openchamber:session-reselected'; type HydratingToolSkeletonRow = { id: string; @@ -193,7 +194,7 @@ export const ChatContainer: React.FC = () => { }, [parentSession, setCurrentSession]); const returnToParentButton = parentSession ? ( - { > Parent - + ) : null; React.useEffect(() => { @@ -255,6 +256,7 @@ export const ChatContainer: React.FC = () => { isPinned, isOverflowing, }); + const { resumeToBottomInstant } = timelineController; React.useEffect(() => { activeTurnChangeRef.current = timelineController.handleActiveTurnChange; @@ -269,6 +271,26 @@ export const ChatContainer: React.FC = () => { resumeToBottom: timelineController.resumeToBottom, }); + React.useEffect(() => { + if (typeof window === 'undefined' || !currentSessionId) { + return; + } + + const handleSessionReselected = (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent.detail !== currentSessionId) { + return; + } + + resumeToBottomInstant(); + }; + + window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener); + return () => { + window.removeEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener); + }; + }, [currentSessionId, resumeToBottomInstant]); + React.useLayoutEffect(() => { const container = scrollRef.current; if (!container) { @@ -366,7 +388,7 @@ export const ChatContainer: React.FC = () => { > {!isDesktopExpandedInput ? (
- +
) : null}
{ 'relative z-10', isDesktopExpandedInput ? 'flex-1 min-h-0 bg-background' - : 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80' + : 'bg-background/95 supports-[backdrop-filter]:bg-background/80' )} > @@ -444,7 +466,7 @@ export const ChatContainer: React.FC = () => { 'relative z-10', isDesktopExpandedInput ? 'flex-1 min-h-0 bg-background' - : 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80' + : 'bg-background/95 supports-[backdrop-filter]:bg-background/80' )} > @@ -507,7 +529,7 @@ export const ChatContainer: React.FC = () => { 'relative z-10', isDesktopExpandedInput ? 'flex-1 min-h-0 bg-background' - : 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80' + : 'bg-background/95 supports-[backdrop-filter]:bg-background/80' )} > {!isDesktopExpandedInput && sessionMessages.length > 0 && ( diff --git a/packages/ui/src/components/chat/ChatEmptyState.tsx b/packages/ui/src/components/chat/ChatEmptyState.tsx index 0dbd93f9..00c30eef 100644 --- a/packages/ui/src/components/chat/ChatEmptyState.tsx +++ b/packages/ui/src/components/chat/ChatEmptyState.tsx @@ -1,12 +1,7 @@ import React from 'react'; -import { RiGitBranchLine } from '@remixicon/react'; - import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { TextLoop } from '@/components/ui/TextLoop'; import { useThemeSystem } from '@/contexts/useThemeSystem'; -import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; -import { useGitStatus, useGitStore } from '@/stores/useGitStore'; const phrases = [ "Fix the failing tests", @@ -27,51 +22,15 @@ const phrases = [ "Add type definitions", ]; -interface ChatEmptyStateProps { - showDraftContext?: boolean; -} - -const ChatEmptyState: React.FC = ({ - showDraftContext = false, -}) => { +const ChatEmptyState: React.FC = () => { const { currentTheme } = useThemeSystem(); - const { git } = useRuntimeAPIs(); - const effectiveDirectory = useEffectiveDirectory(); - const { setActiveDirectory, fetchStatus } = useGitStore(); - const gitStatus = useGitStatus(effectiveDirectory ?? null); // Use theme's muted foreground for secondary text const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)'; - const branchName = typeof gitStatus?.current === 'string' && gitStatus.current.trim().length > 0 - ? gitStatus.current.trim() - : null; - - React.useEffect(() => { - if (!showDraftContext || !effectiveDirectory) { - return; - } - - setActiveDirectory(effectiveDirectory); - - const state = useGitStore.getState().directories.get(effectiveDirectory); - if (!state?.status && state?.isGitRepo !== false) { - void fetchStatus(effectiveDirectory, git, { silent: true }); - } - }, [effectiveDirectory, fetchStatus, git, setActiveDirectory, showDraftContext]); return (
- {showDraftContext && ( -
- {branchName && ( -
- - {branchName} -
- )} -
- )} { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const normalized = trimmed.replace(/\\/g, '/'); + if (normalized === '/') { + return '/'; + } + return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; +}; + +const getProjectDisplayLabel = (project: { label?: string; path: string }): string => { + const label = project.label?.trim(); + if (label) { + return label; + } + return formatDirectoryName(project.path); +}; + +const getProjectIconColor = (projectColor?: string | null): string | undefined => { + if (!projectColor) { + return undefined; + } + return PROJECT_COLOR_MAP[projectColor] ?? undefined; +}; const appendWithLineBreaks = (base: string, next: string): string => { const separator = !base @@ -154,10 +195,17 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const skillRef = React.useRef(null); // Ref to track current message value without triggering re-renders in effects const messageRef = React.useRef(message); + const draftPersistTimerRef = React.useRef | null>(null); + const skipNextDraftPersistRef = React.useRef(false); + const lastPersistedDraftRef = React.useRef>(new Map()); + const currentSessionIdForDraftRef = React.useRef(null); const sendMessage = useSessionStore((state) => state.sendMessage); const currentSessionId = useSessionStore((state) => state.currentSessionId); - const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open); + const newSessionDraft = useSessionStore((state) => state.newSessionDraft); + const newSessionDraftOpen = Boolean(newSessionDraft?.open); + const setNewSessionDraftTarget = useSessionStore((state) => state.setNewSessionDraftTarget); + const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject); const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation); const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort); const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId); @@ -169,18 +217,25 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText); const pendingInputText = useSessionStore((state) => state.pendingInputText); const consumePendingSyntheticParts = useSessionStore((state) => state.consumePendingSyntheticParts); + const currentManagementSessionId = useSessionManagementStore((state) => state.currentSessionId); + const projects = useProjectsStore((state) => state.projects); + const activeProjectId = useProjectsStore((state) => state.activeProjectId); + const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore(); const agents = getVisibleAgents(); const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]); const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore(); const { working } = useAssistantStatus(); + const { git: runtimeGit } = useRuntimeAPIs(); const { currentTheme } = useThemeSystem(); const chatSearchDirectory = useChatSearchDirectory(); const [showAbortStatus, setShowAbortStatus] = React.useState(false); const [textareaScrollTop, setTextareaScrollTop] = React.useState(0); + const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept); const isDesktopExpanded = isExpandedInput && !isMobile; + const chatInputRadius = 'var(--radius-lg)'; const sendableAttachedFiles = React.useMemo( () => attachedFiles.filter((file) => file.source !== 'server'), @@ -423,6 +478,29 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo messageRef.current = message; }, [message]); + React.useEffect(() => { + currentSessionIdForDraftRef.current = currentSessionId; + }, [currentSessionId]); + + const persistDraftImmediately = React.useCallback((sessionId: string | null, draft: string) => { + const key = getDraftKey(sessionId); + const lastPersisted = lastPersistedDraftRef.current.get(key); + if (lastPersisted === draft) { + return; + } + + saveStoredDraft(sessionId, draft); + lastPersistedDraftRef.current.set(key, draft); + }, []); + + const clearPendingDraftPersist = React.useCallback(() => { + if (!draftPersistTimerRef.current) { + return; + } + clearTimeout(draftPersistTimerRef.current); + draftPersistTimerRef.current = null; + }, []); + // Handle initial draft restoration and text selection const hasHandledInitialDraftRef = React.useRef(false); React.useEffect(() => { @@ -455,10 +533,12 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const oldSessionId = prevSessionIdRef.current; prevSessionIdRef.current = currentSessionId; setInputMode('normal'); + clearPendingDraftPersist(); + skipNextDraftPersistRef.current = true; if (persistChatDraft) { // Save current draft for the session we're leaving - saveStoredDraft(oldSessionId, messageRef.current); + persistDraftImmediately(oldSessionId, messageRef.current); // Restore draft for the session we're entering const newDraft = getStoredDraft(currentSessionId); setMessage(newDraft); @@ -472,7 +552,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo setMessage(''); } } - }, [currentSessionId, persistChatDraft]); + }, [clearPendingDraftPersist, currentSessionId, persistChatDraft, persistDraftImmediately]); // Focus textarea when new session draft is opened const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen); @@ -494,16 +574,37 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo // Persist chat input draft to localStorage per session (only if setting enabled) React.useEffect(() => { if (!persistChatDraft) { - // Clear stored draft for current session when setting is disabled - try { - localStorage.removeItem(getDraftKey(currentSessionId)); - } catch { - // Ignore - } + clearPendingDraftPersist(); + persistDraftImmediately(currentSessionId, ''); return; } - saveStoredDraft(currentSessionId, message); - }, [message, persistChatDraft, currentSessionId]); + + if (skipNextDraftPersistRef.current) { + skipNextDraftPersistRef.current = false; + return; + } + + clearPendingDraftPersist(); + const draftSnapshot = message; + const sessionSnapshot = currentSessionId; + draftPersistTimerRef.current = setTimeout(() => { + draftPersistTimerRef.current = null; + persistDraftImmediately(sessionSnapshot, draftSnapshot); + }, CHAT_DRAFT_PERSIST_DEBOUNCE_MS); + + return () => { + clearPendingDraftPersist(); + }; + }, [clearPendingDraftPersist, currentSessionId, message, persistChatDraft, persistDraftImmediately]); + + React.useEffect(() => { + return () => { + clearPendingDraftPersist(); + if (persistChatDraft) { + persistDraftImmediately(currentSessionIdForDraftRef.current, messageRef.current); + } + }; + }, [clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]); // Session activity for queue availability and controls const { phase: sessionPhase } = useCurrentSessionActivity(); @@ -2164,6 +2265,243 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const footerGapClass = 'gap-x-1.5 gap-y-0'; const isVSCode = isVSCodeRuntime(); + const showDraftTargetSelectors = newSessionDraftOpen && !isVSCode; + + const selectedDraftProject = React.useMemo(() => { + const explicit = newSessionDraft?.selectedProjectId + ? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null + : null; + if (explicit) { + return explicit; + } + + const active = activeProjectId + ? projects.find((project) => project.id === activeProjectId) ?? null + : null; + if (active) { + return active; + } + + return projects[0] ?? null; + }, [activeProjectId, newSessionDraft?.selectedProjectId, projects]); + + const selectedDraftProjectPath = React.useMemo( + () => normalizePath(selectedDraftProject?.path ?? null), + [selectedDraftProject?.path], + ); + + const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath); + const fetchBranches = useGitStore((state) => state.fetchBranches); + const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false); + + React.useEffect(() => { + if (!showDraftTargetSelectors || !selectedDraftProjectPath || !selectedDraftProject || !runtimeGit) { + setIsDiscoveringDraftBranches(false); + return; + } + + if (selectedDraftProjectBranches?.all) { + setIsDiscoveringDraftBranches(false); + return; + } + + let cancelled = false; + setIsDiscoveringDraftBranches(true); + + void fetchBranches(selectedDraftProjectPath, runtimeGit) + .finally(() => { + if (!cancelled) { + setIsDiscoveringDraftBranches(false); + } + }); + + return () => { + cancelled = true; + }; + }, [fetchBranches, runtimeGit, selectedDraftProject, selectedDraftProjectBranches?.all, selectedDraftProjectPath, showDraftTargetSelectors]); + + const projectRootBranchOption = React.useMemo(() => { + if (!selectedDraftProject) { + return null; + } + const value = normalizePath(selectedDraftProject.path); + if (!value) { + return null; + } + const projectRootBranch = selectedDraftProjectBranches?.current?.trim() ?? ''; + if (!projectRootBranch) { + return null; + } + return { + value, + label: projectRootBranch, + }; + }, [selectedDraftProject, selectedDraftProjectBranches]); + + const worktreeBranchOptions = React.useMemo(() => { + if (!selectedDraftProject) { + return [] as Array<{ value: string; label: string }>; + } + + const seen = new Set(); + const options: Array<{ value: string; label: string }> = []; + const rootValue = projectRootBranchOption?.value ?? null; + + const worktrees = (() => { + if (!selectedDraftProjectPath) { + return []; + } + return availableWorktreesByProject.get(selectedDraftProjectPath) + ?? availableWorktreesByProject.get(selectedDraftProject.path) + ?? []; + })(); + + worktrees + .slice() + .sort((a, b) => a.branch.localeCompare(b.branch)) + .forEach((worktree) => { + const normalizedValue = normalizePath(worktree.path); + if (!normalizedValue || normalizedValue === rootValue || seen.has(normalizedValue)) { + return; + } + seen.add(normalizedValue); + options.push({ + value: normalizedValue, + label: worktree.branch?.trim() || formatDirectoryName(worktree.path), + }); + }); + + return options; + }, [availableWorktreesByProject, projectRootBranchOption?.value, selectedDraftProject, selectedDraftProjectPath]); + + const selectedDraftDirectory = React.useMemo( + () => normalizePath(newSessionDraft?.directoryOverride ?? null) ?? selectedDraftProjectPath, + [newSessionDraft?.directoryOverride, selectedDraftProjectPath], + ); + + const draftBranchItems = React.useMemo(() => { + const baseItems: Array<{ value: string; label: string }> = []; + if (projectRootBranchOption) { + baseItems.push(projectRootBranchOption); + } + baseItems.push(...worktreeBranchOptions); + + if (!selectedDraftDirectory) { + return baseItems; + } + if (baseItems.some((option) => option.value === selectedDraftDirectory)) { + return baseItems; + } + return [ + ...baseItems, + { value: selectedDraftDirectory, label: formatDirectoryName(selectedDraftDirectory) }, + ]; + }, [projectRootBranchOption, selectedDraftDirectory, worktreeBranchOptions]); + + const selectedDraftBranchLabel = React.useMemo(() => { + const selectedValue = selectedDraftDirectory ?? draftBranchItems[0]?.value ?? null; + if (!selectedValue) { + return null; + } + return draftBranchItems.find((item) => item.value === selectedValue)?.label ?? formatDirectoryName(selectedValue); + }, [draftBranchItems, selectedDraftDirectory]); + + const selectedDraftBranchIsKnown = React.useMemo(() => { + if (!selectedDraftDirectory) { + return true; + } + if (projectRootBranchOption?.value === selectedDraftDirectory) { + return true; + } + return worktreeBranchOptions.some((option) => option.value === selectedDraftDirectory); + }, [projectRootBranchOption?.value, selectedDraftDirectory, worktreeBranchOptions]); + + const shouldShowDraftBranchSelector = React.useMemo(() => { + if (isDiscoveringDraftBranches) { + return false; + } + if (projectRootBranchOption) { + return true; + } + return worktreeBranchOptions.length > 0; + }, [isDiscoveringDraftBranches, projectRootBranchOption, worktreeBranchOptions.length]); + + const handleDraftProjectChange = React.useCallback((projectId: string) => { + const project = projects.find((entry) => entry.id === projectId); + if (!project) { + return; + } + if (activeProjectId !== projectId) { + setActiveProjectIdOnly(projectId); + } + setNewSessionDraftTarget({ + projectId, + directoryOverride: project.path, + }); + }, [activeProjectId, projects, setActiveProjectIdOnly, setNewSessionDraftTarget]); + + const handleDraftDirectoryChange = React.useCallback((directory: string) => { + if (!selectedDraftProject) { + return; + } + setNewSessionDraftTarget({ + projectId: selectedDraftProject.id, + directoryOverride: directory, + }); + }, [selectedDraftProject, setNewSessionDraftTarget]); + + const renderProjectLabelWithIcon = React.useCallback((project: { + id: string; + path: string; + label?: string; + icon?: string | null; + color?: string | null; + iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null; + iconBackground?: string | null; + }) => { + const imageUrl = getProjectIconImageUrl( + { id: project.id, iconImage: project.iconImage ?? null }, + { + themeVariant: currentTheme.metadata.variant, + iconColor: currentTheme.colors.surface.foreground, + }, + ); + const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null; + const iconColor = getProjectIconColor(project.color); + + return ( + + {imageUrl ? ( + + + + ) : ProjectIcon ? ( + + ) : ( + + )} + {getProjectDisplayLabel(project)} + + ); + }, [currentTheme.colors.surface.foreground, currentTheme.metadata.variant]); + + React.useEffect(() => { + if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) { + return; + } + const valid = draftBranchItems.some((option) => option.value === selectedDraftDirectory); + if (valid) { + return; + } + setNewSessionDraftTarget({ + projectId: selectedDraftProject.id, + directoryOverride: selectedDraftProject.path, + }); + }, [draftBranchItems, selectedDraftDirectory, selectedDraftProject, setNewSessionDraftTarget, showDraftTargetSelectors]); + const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5'); const buttonSizeClass = isMobile ? 'h-8 w-8' : (isVSCode ? 'h-5 w-5' : 'h-6 w-6'); const sendIconSizeClass = isMobile ? 'h-4 w-4' : (isVSCode ? 'h-3.5 w-3.5' : 'h-4 w-4'); @@ -2172,6 +2510,73 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const iconButtonBaseClass = 'flex cursor-pointer items-center justify-center text-foreground transition-none outline-none focus:outline-none flex-shrink-0 disabled:cursor-not-allowed'; const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass); + const permissionScopeSessionId = currentSessionId ?? currentManagementSessionId; + const permissionAutoAcceptEnabled = usePermissionStore((state) => { + if (!permissionScopeSessionId) { + return false; + } + return state.isSessionAutoAccepting(permissionScopeSessionId); + }); + + const handlePermissionAutoAcceptToggle = React.useCallback(() => { + if (!permissionScopeSessionId) { + toast.error('Open a session first'); + return; + } + + const nextEnabled = !permissionAutoAcceptEnabled; + setSessionAutoAccept(permissionScopeSessionId, nextEnabled).catch(() => { + toast.error('Failed to toggle permission auto-accept'); + }); + }, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]); + + const permissionAutoAcceptAriaLabel = permissionAutoAcceptEnabled + ? 'Disable permission auto-accept' + : 'Enable permission auto-accept'; + const permissionAutoAcceptTooltipLabel = permissionAutoAcceptEnabled + ? 'Permission auto-accept: on' + : 'Permission auto-accept: off'; + + const permissionAutoAcceptButton = ( + + ); + + const permissionAutoAcceptButtonWithTooltip = ( + + + {permissionAutoAcceptButton} + + + {permissionAutoAcceptTooltipLabel} + + + ); // Send button - respects queue mode setting const sendButton = ( @@ -2536,6 +2941,74 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo showAssistantStatus={false} showTodos /> + {showDraftTargetSelectors && selectedDraftProject ? ( +
+ + + {shouldShowDraftBranchSelector ? ( + + ) : null} +
+ ) : null}
= ({ onOpenSettings, scrollToBo isDragging && "ring-2 ring-primary ring-offset-2" )} style={{ - borderRadius: cornerRadius, + borderRadius: chatInputRadius, backgroundColor: currentTheme?.colors?.surface?.subtle, }} ref={dropZoneRef} @@ -2714,8 +3187,8 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo flex: isDesktopExpanded ? '1 1 auto' : 'none', height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined, maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined, - borderTopLeftRadius: cornerRadius, - borderTopRightRadius: cornerRadius, + borderTopLeftRadius: chatInputRadius, + borderTopRightRadius: chatInputRadius, }} rows={1} /> @@ -2727,16 +3200,17 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo isMobile ? 'flex items-center gap-x-1.5' : cn('flex items-center justify-between', footerGapClass) )} style={{ - borderBottomLeftRadius: cornerRadius, - borderBottomRightRadius: cornerRadius, + borderBottomLeftRadius: chatInputRadius, + borderBottomRightRadius: chatInputRadius, }} data-chat-input-footer="true" > {isMobile ? ( <>
-
+
{attachmentsControls} + {permissionAutoAcceptButton}
@@ -2801,6 +3275,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo
+ {permissionAutoAcceptButtonWithTooltip}
diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index f03ac170..ca85ba6b 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -971,6 +971,7 @@ const ChatMessage: React.FC = ({ const assistantTopPaddingClass = !isUser && shouldShowHeader ? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0') : 'pt-0'; + const userMessageRadius = 'var(--radius-lg)'; return ( <> @@ -995,7 +996,14 @@ const ChatMessage: React.FC = ({ >
-
+
; effectiveDirectory: string; readFile?: (path: string) => Promise<{ content: string; path: string }>; editor?: EditorAPI; preferRuntimeEditor?: boolean; + deferValidationUntilIdle?: boolean; }) => { const validationCacheRef = React.useRef>(new Map()); const inFlightValidationsRef = React.useRef>>(new Map()); @@ -1048,6 +1050,9 @@ const useFileReferenceInteractions = ({ }; const runValidationSweep = async (paths: string[], expectedPassID: number) => { + if (deferValidationUntilIdle) { + return; + } if (isValidationSweepRunningRef.current || paths.length === 0) { return; } @@ -1208,9 +1213,13 @@ const useFileReferenceInteractions = ({ annotationDebounceRef.current = window.setTimeout(() => { annotationDebounceRef.current = null; void annotateFileLinks(); - }, 120); + }, deferValidationUntilIdle ? 280 : 120); + }); + observer.observe(container, { + childList: true, + subtree: true, + ...(deferValidationUntilIdle ? {} : { characterData: true }), }); - observer.observe(container, { childList: true, subtree: true, characterData: true }); container.addEventListener('click', handleClick); container.addEventListener('keydown', handleKeyDown); @@ -1226,7 +1235,7 @@ const useFileReferenceInteractions = ({ container.removeEventListener('click', handleClick); container.removeEventListener('keydown', handleKeyDown); }; - }, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, readFile]); + }, [containerRef, deferValidationUntilIdle, editor, effectiveDirectory, preferRuntimeEditor, readFile]); }; const useMermaidInlineInteractions = ({ @@ -1345,6 +1354,7 @@ export const MarkdownRenderer: React.FC = ({ readFile: files.readFile, editor, preferRuntimeEditor: runtime.isVSCode, + deferValidationUntilIdle: isStreaming, }); const shikiThemes = useMarkdownShikiThemes(); @@ -1358,10 +1368,7 @@ export const MarkdownRenderer: React.FC = ({ ? 'streamdown-content streamdown-reasoning' : 'streamdown-content'; - const streamdownAnimated = React.useMemo( - () => ({ animation: 'blurIn' as const, duration: 150, easing: 'ease-out' }), - [], - ); + const streamdownAnimated = undefined; const markdownContent = (
diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 28b4072e..5a9eaa0b 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -318,6 +318,33 @@ const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeD }; }; +const normalizedMessageBySource = new WeakMap(); + +const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => { + const cached = normalizedMessageBySource.get(message); + if (cached) { + return cached; + } + + const filteredParts = filterSyntheticParts(message.parts); + const normalized = filteredParts === message.parts + ? message + : { + ...message, + parts: filteredParts, + }; + + normalizedMessageBySource.set(message, normalized); + return normalized; +}; + +const isAssistantTextOnlyMessage = (message: ChatMessageEntry): boolean => { + if (resolveMessageRole(message) !== 'assistant') { + return false; + } + return message.parts.length > 0 && message.parts.every((part) => part?.type === 'text'); +}; + interface MessageListProps { sessionKey: string; turnStart: number; @@ -813,6 +840,11 @@ const MessageList = React.forwardRef(({ previousOrder: string[]; animatedIds: Set; }>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() }); + const baseDisplayCacheRef = React.useRef<{ + input: ChatMessageEntry[]; + output: ChatMessageEntry[]; + outputIndexById: Map; + } | null>(null); const stableOnMessageContentChange = useStableEvent(onMessageContentChange); const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers); @@ -843,8 +875,51 @@ const MessageList = React.forwardRef(({ const baseDisplayMessages = React.useMemo(() => { - const seenIdsFromTail = new Set(); + const cached = baseDisplayCacheRef.current; + const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined; + const canUseTailFastPath = Boolean(lastMessage && isAssistantTextOnlyMessage(lastMessage)); + if (cached && canUseTailFastPath && cached.input.length === messages.length && messages.length > 0) { + let changedCount = 0; + let changedIndex = -1; + let idsStable = true; + + for (let index = 0; index < messages.length; index += 1) { + if (messages[index]?.info?.id !== cached.input[index]?.info?.id) { + idsStable = false; + break; + } + if (messages[index] !== cached.input[index]) { + changedCount += 1; + changedIndex = index; + if (changedCount > 1) { + break; + } + } + } + + if (idsStable && changedCount === 1 && changedIndex === messages.length - 1) { + const changedMessage = messages[changedIndex]; + const previousMessage = changedIndex > 0 ? messages[changedIndex - 1] : undefined; + const bridgeSensitive = isUserSubtaskMessage(previousMessage) || isUserShellMarkerMessage(previousMessage); + + if (changedMessage && isAssistantTextOnlyMessage(changedMessage) && !bridgeSensitive) { + const outputIndex = cached.outputIndexById.get(changedMessage.info.id); + if (outputIndex !== undefined) { + const nextOutput = [...cached.output]; + nextOutput[outputIndex] = getNormalizedMessageForDisplay(changedMessage); + baseDisplayCacheRef.current = { + input: messages, + output: nextOutput, + outputIndexById: cached.outputIndexById, + }; + return nextOutput; + } + } + } + } + + const seenIdsFromTail = new Set(); const dedupedMessages: ChatMessageEntry[] = []; for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; @@ -855,26 +930,13 @@ const MessageList = React.forwardRef(({ } seenIdsFromTail.add(messageId); } - dedupedMessages.push(message); + dedupedMessages.push(getNormalizedMessageForDisplay(message)); } dedupedMessages.reverse(); - const normalizedMessages = dedupedMessages - .map((message) => { - const filteredParts = filterSyntheticParts(message.parts); - const normalized = filteredParts === message.parts - ? message - : { - ...message, - parts: filteredParts, - }; - return normalized; - }); - const output: ChatMessageEntry[] = []; - - for (let index = 0; index < normalizedMessages.length; index += 1) { - const current = normalizedMessages[index]; + for (let index = 0; index < dedupedMessages.length; index += 1) { + const current = dedupedMessages[index]; const previous = output.length > 0 ? output[output.length - 1] : undefined; if (isUserSubtaskMessage(previous)) { @@ -896,6 +958,19 @@ const MessageList = React.forwardRef(({ output.push(current); } + const outputIndexById = new Map(); + output.forEach((message, index) => { + const id = message.info?.id; + if (typeof id === 'string' && id.length > 0) { + outputIndexById.set(id, index); + } + }); + baseDisplayCacheRef.current = { + input: messages, + output, + outputIndexById, + }; + return output; }, [messages]); diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index e09c0e30..dfbdade3 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -37,7 +37,6 @@ import { Input } from '@/components/ui/input'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -import { Switch } from '@/components/ui/switch'; import { TextLoop } from '@/components/ui/TextLoop'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs'; @@ -350,14 +349,6 @@ export const ModelControls: React.FC = ({ ? (sessionSavedAgentName || stickySessionAgentName || currentAgentName) : currentAgentName; - const sessionIdForEditMode = currentSessionId ?? '__global__'; - const sessionEditMode = useContextStore((state) => { - if (!uiAgentName) { - return undefined; - } - return state.getSessionAgentEditMode(sessionIdForEditMode, uiAgentName, 'ask'); - }); - const setSessionAgentEditMode = useContextStore((state) => state.setSessionAgentEditMode); const { toggleFavoriteModel, isFavoriteModel, @@ -506,21 +497,6 @@ export const ModelControls: React.FC = ({ return getCurrentAgent?.(); }, [agents, getCurrentAgent, uiAgentName]); - const agentEditAction = React.useMemo(() => { - if (!currentAgent) { - return 'deny'; - } - return resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'allow'; - }, [currentAgent]); - - const selectionContextReady = Boolean(uiAgentName); - - const approveEditsAvailable = agentEditAction === 'ask'; - const approveEditsChecked = approveEditsAvailable - ? sessionEditMode === 'allow' || sessionEditMode === 'full' - : agentEditAction === 'allow'; - const approveEditsDisabled = !selectionContextReady || !approveEditsAvailable; - const sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default'; const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8'; const editToggleIconClass = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4'; @@ -545,13 +521,6 @@ export const ModelControls: React.FC = ({ return ; }, [editToggleIconClass]); - const handleApproveEditsToggle = React.useCallback((checked: boolean) => { - if (!selectionContextReady || !currentAgentName || !approveEditsAvailable) { - return; - } - setSessionAgentEditMode(sessionIdForEditMode, currentAgentName, checked ? 'allow' : 'ask', 'ask'); - }, [approveEditsAvailable, currentAgentName, selectionContextReady, setSessionAgentEditMode, sessionIdForEditMode]); - const currentProvider = getCurrentProvider(); const models = Array.isArray(currentProvider?.models) ? currentProvider.models : []; @@ -1819,23 +1788,6 @@ export const ModelControls: React.FC = ({ onClose={closeMobilePanel} title="Select agent" contentMaxHeightClassName="max-h-[min(52dvh,360px)]" - footer={( -
- - Auto-approve edits - - -
- )} >
{selectableDesktopAgents.map((agent) => { @@ -2302,10 +2254,9 @@ export const ModelControls: React.FC = ({ {/* Favorites Section */} {filteredFavorites.length > 0 && ( - <> +
Favorites @@ -2314,16 +2265,15 @@ export const ModelControls: React.FC = ({ const idx = currentFlatIndex++; return renderModelRow(model, providerID, modelID, 'fav', idx, modelSelectedIndex === idx); })} - +
)} {/* Recents Section */} {filteredRecents.length > 0 && ( - <> +
{filteredFavorites.length > 0 && } Recent @@ -2332,7 +2282,7 @@ export const ModelControls: React.FC = ({ const idx = currentFlatIndex++; return renderModelRow(model, providerID, modelID, 'recent', idx, modelSelectedIndex === idx); })} - +
)} {/* Separator before providers */} @@ -2342,7 +2292,7 @@ export const ModelControls: React.FC = ({ {/* All Providers - Flat List */} {providerSections.map(({ provider, isExpanded, visibleModels }, index) => ( - +
{index > 0 && }
= ({ } }} className={cn( - 'typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex w-full items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30', - 'bg-[var(--surface-elevated)] text-left transition-colors', + 'typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex w-full items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30', + 'text-left transition-colors', forceExpandProviders ? 'cursor-default' : 'cursor-pointer' )} aria-expanded={isExpanded} @@ -2392,7 +2342,7 @@ export const ModelControls: React.FC = ({ const idx = currentFlatIndex++; return renderModelRow(model, provider.id as string, model.id as string, 'provider', idx, modelSelectedIndex === idx); })} - +
))}
@@ -2755,24 +2705,6 @@ export const ModelControls: React.FC = ({ )}
- -
-
-
- - Auto-approve edits - - -
-
-
{renderAgentTooltipContent()} diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 7a35238e..5899be54 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -51,6 +51,7 @@ export interface UseChatTimelineControllerResult { loadEarlier: () => Promise; revealBufferedTurns: () => Promise; resumeToBottom: () => void; + resumeToBottomInstant: () => void; scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise; scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise; captureViewportAnchor: () => ViewportAnchor | null; @@ -394,6 +395,14 @@ export const useChatTimelineController = ({ scrollToBottom({ force: true }); }, [scrollToBottom]); + const resumeToBottomInstant = React.useCallback(() => { + const nextStart = getInitialTurnStart(turnModelRef.current.turnCount); + setTurnStart(nextStart); + setPendingRevealWork(false); + setIsLoadingOlder(false); + scrollToBottom({ instant: true, force: true }); + }, [scrollToBottom]); + const handleActiveTurnChange = React.useCallback((turnId: string | null) => { setActiveTurnId(turnId); }, []); @@ -411,6 +420,7 @@ export const useChatTimelineController = ({ loadEarlier, revealBufferedTurns, resumeToBottom, + resumeToBottomInstant, scrollToTurn, scrollToMessage, captureViewportAnchor, diff --git a/packages/ui/src/components/chat/lib/blockingRequests.ts b/packages/ui/src/components/chat/lib/blockingRequests.ts index 39426221..d95507ca 100644 --- a/packages/ui/src/components/chat/lib/blockingRequests.ts +++ b/packages/ui/src/components/chat/lib/blockingRequests.ts @@ -18,11 +18,30 @@ export const collectVisibleSessionIdsForBlockingRequests = ( return []; } - const childIds = sessions - .filter((session) => session.parentID === currentSessionId) - .map((session) => session.id); + const childrenByParent = new Map(); + for (const session of sessions) { + if (!session.parentID) { + continue; + } + const existing = childrenByParent.get(session.parentID) ?? []; + existing.push(session.id); + childrenByParent.set(session.parentID, existing); + } - return [currentSessionId, ...childIds]; + const scoped = [currentSessionId]; + const seen = new Set(scoped); + for (const sessionId of scoped) { + const children = childrenByParent.get(sessionId) ?? []; + for (const childId of children) { + if (seen.has(childId)) { + continue; + } + seen.add(childId); + scoped.push(childId); + } + } + + return scoped; }; export const flattenBlockingRequests = ( diff --git a/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts b/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts index 251ce60f..94acb55c 100644 --- a/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts +++ b/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts @@ -80,6 +80,7 @@ export const createScrollSpy = (input: ScrollSpyInput) => { let ro: ResizeObserver | undefined; let mo: MutationObserver | undefined; let frame: number | undefined; + let roDebounce: ReturnType | undefined; let active: string | undefined; let dirty = true; @@ -198,12 +199,17 @@ export const createScrollSpy = (input: ScrollSpyInput) => { } } + clearTimeout(roDebounce); + roDebounce = undefined; ro?.disconnect(); ro = undefined; if (CtorRO) { ro = new CtorRO(() => { - dirty = true; - schedule(); + clearTimeout(roDebounce); + roDebounce = setTimeout(() => { + dirty = true; + schedule(); + }, 100); }); ro.observe(container); for (const element of nodes.values()) { @@ -218,7 +224,15 @@ export const createScrollSpy = (input: ScrollSpyInput) => { dirty = true; schedule(); }); - mo.observe(container, { subtree: true, childList: true, characterData: true }); + const moConfig: MutationObserverInit = { + subtree: true, + childList: true, + }; + if (!CtorRO) { + moConfig.characterData = true; + moConfig.characterDataOldValue = false; + } + mo.observe(container, moConfig); } dirty = true; @@ -292,6 +306,8 @@ export const createScrollSpy = (input: ScrollSpyInput) => { caf(frame); } frame = undefined; + clearTimeout(roDebounce); + roDebounce = undefined; clear(); io?.disconnect(); ro?.disconnect(); diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts b/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts index d7a486c0..f83a2677 100644 --- a/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts +++ b/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts @@ -81,69 +81,8 @@ interface ProjectActivityResult { export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivityResult => { const activityParts: TurnActivityRecord[] = []; - const hasTools = input.assistantMessages.some((message) => - message.parts.some((part) => part.type === 'tool') - ); - const hasReasoning = input.assistantMessages.some((message) => - message.parts.some((part) => part.type === 'reasoning' && Boolean(getPartText(part))) - ); - - input.assistantMessages.forEach((message) => { - message.parts.forEach((part, partIndex) => { - if (part.type === 'tool') { - activityParts.push({ - ...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex), - kind: 'tool', - }); - return; - } - - if (part.type === 'reasoning') { - const text = getPartText(part); - if (!text) { - return; - } - activityParts.push({ - ...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex), - kind: 'reasoning', - }); - return; - } - - if (!input.showTextJustificationActivity || part.type !== 'text') { - return; - } - - if (!isAssistantMessageCompleted(message)) { - return; - } - - const finish = getMessageFinish(message); - if (!finish) { - return; - } - - if (finish === 'stop') { - return; - } - - const text = getPartText(part); - if (!text) { - return; - } - - activityParts.push({ - ...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex), - kind: 'justification', - }); - }); - }); - - const activitySegments: TurnActivityGroup[] = []; - const activityByPart = new WeakMap(); - activityParts.forEach((activity) => { - activityByPart.set(activity.part as object, activity); - }); + let hasTools = false; + let hasReasoning = false; const taskMessageById = new Map(); const taskOrder: string[] = []; @@ -151,32 +90,66 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit let currentAfterToolPartId: string | null = null; input.assistantMessages.forEach((message) => { - const messageId = message.info.id; + const messageCompleted = isAssistantMessageCompleted(message); + const finish = getMessageFinish(message); message.parts.forEach((part, partIndex) => { - if (part.type === 'tool') { - const toolName = (part as { tool?: unknown }).tool; - if (isStandaloneTool(toolName)) { - const toolPartId = part.id ?? `${messageId}-part-${partIndex}-${part.type}`; - if (!taskMessageById.has(toolPartId)) { - taskMessageById.set(toolPartId, messageId); - taskOrder.push(toolPartId); - } - currentAfterToolPartId = toolPartId; - return; - } + const isTool = part.type === 'tool'; + if (isTool) { + hasTools = true; } - const activity = activityByPart.get(part as object); - if (!activity) { + const text = part.type === 'reasoning' || part.type === 'text' + ? getPartText(part) + : undefined; + + if (part.type === 'reasoning' && text) { + hasReasoning = true; + } + + const toolName = isTool + ? (part as { tool?: unknown }).tool + : undefined; + const standaloneTool = isTool && isStandaloneTool(toolName); + if (standaloneTool) { + const toolPartId = part.id ?? `${message.info.id}-part-${partIndex}-${part.type}`; + if (!taskMessageById.has(toolPartId)) { + taskMessageById.set(toolPartId, message.info.id); + taskOrder.push(toolPartId); + } + currentAfterToolPartId = toolPartId; + } + + let kind: TurnActivityRecord['kind'] | null = null; + if (isTool) { + kind = 'tool'; + } else if (part.type === 'reasoning') { + if (text) { + kind = 'reasoning'; + } + } else if ( + input.showTextJustificationActivity + && part.type === 'text' + && messageCompleted + && typeof finish === 'string' + && finish !== 'stop' + && text + ) { + kind = 'justification'; + } + + if (!kind) { return; } - if (activity.kind === 'tool') { - const toolName = (activity.part as { tool?: unknown }).tool; - if (isStandaloneTool(toolName)) { - return; - } + const activity: TurnActivityRecord = { + ...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex), + kind, + }; + activityParts.push(activity); + + if (kind === 'tool' && standaloneTool) { + return; } const list = partsByAfterTool.get(currentAfterToolPartId) ?? []; @@ -185,6 +158,8 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit }); }); + const activitySegments: TurnActivityGroup[] = []; + const pickStartAnchor = (segmentParts: TurnActivityRecord[]): string | undefined => { if (segmentParts.length === 0) { return undefined; diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index d545caf1..7c19ebb1 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -24,6 +24,12 @@ interface SelectionPayload { const DESKTOP_MENU_SIDE_MARGIN_PX = 8; const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280; +const BLOCK_TAGS = new Set([ + 'address', 'article', 'aside', 'blockquote', 'dd', 'div', 'dl', 'dt', + 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', + 'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre', + 'section', 'table', 'ul', +]); const normalizeLineBreaks = (value: string): string => value.replace(/\r\n?/g, '\n'); @@ -103,6 +109,11 @@ const renderBlockMarkdownNode = (node: Node): string => { return `\`\`\`${language}\n${code}\n\`\`\``; } + if (tag === 'code') { + const code = normalizeLineBreaks(element.textContent || '').trim(); + return code ? `\`${code.replace(/`/g, '\\`')}\`` : ''; + } + if (tag === 'ul') return renderListMarkdown(element, false); if (tag === 'ol') return renderListMarkdown(element, true); @@ -137,8 +148,34 @@ const renderBlockMarkdownNode = (node: Node): string => { return trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join('')); }; +const isInlineSelectionFragment = (fragment: DocumentFragment): boolean => { + return Array.from(fragment.childNodes).every((node) => { + if (node.nodeType === Node.TEXT_NODE) { + return true; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return true; + } + + const element = node as HTMLElement; + return !BLOCK_TAGS.has(element.tagName.toLowerCase()); + }); +}; + const rangeToMarkdown = (range: Range, plainText: string): string => { const fragment = range.cloneContents(); + + if (isInlineSelectionFragment(fragment)) { + const inlineMarkdown = trimSelectionValue( + Array.from(fragment.childNodes) + .map((node) => renderInlineMarkdownNode(node)) + .join('') + ); + if (inlineMarkdown) { + return inlineMarkdown; + } + } + const markdown = Array.from(fragment.childNodes) .map((node) => renderBlockMarkdownNode(node)) .filter((value) => value.length > 0) @@ -436,6 +473,8 @@ export const TextSelectionMenu: React.FC = ({ containerR )} style={{ paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))', + backdropFilter: 'blur(28px)', + WebkitBackdropFilter: 'blur(28px)', }} > -
+
+ Need SSH instances?
Manage them in Settings.
+
)} diff --git a/packages/ui/src/components/layout/BottomTerminalDock.tsx b/packages/ui/src/components/layout/BottomTerminalDock.tsx index 6751155a..b680816c 100644 --- a/packages/ui/src/components/layout/BottomTerminalDock.tsx +++ b/packages/ui/src/components/layout/BottomTerminalDock.tsx @@ -48,7 +48,7 @@ export const BottomTerminalDock: React.FC = ({ isOpen, if (!parentHeight || parentHeight <= 0) { return; } - const next = Math.round(parentHeight); + const next = Math.max(0, Math.round(parentHeight)); setFullscreenHeight((prev) => (prev === next ? prev : next)); }; @@ -103,7 +103,7 @@ export const BottomTerminalDock: React.FC = ({ isOpen, } const appliedHeight = isOpen - ? (isFullscreen ? Math.max(standardHeight, fullscreenHeight ?? standardHeight) : standardHeight) + ? (isFullscreen ? Math.max(0, fullscreenHeight ?? standardHeight) : standardHeight) : 0; const handlePointerDown = (event: React.PointerEvent) => { @@ -146,8 +146,8 @@ export const BottomTerminalDock: React.FC = ({ isOpen, {isOpen && !isFullscreen && (
{ const isFileTabActive = activeTab?.mode === 'file'; const header = ( -
+
{ reorderContextPanelTabs(directoryKey, activeTabID, overTabID); }} layoutMode="scrollable" + variant="active-pill" + activePillLowercase={false} + activePillInsetClassName="gap-0.5 pt-0.5 pb-1.5" /> -
+
- - -

Open sessions ({shortcutLabel('toggle_sidebar')})

-
- + const renderDesktopGitHubControl = () => { + if (!githubAuthStatus?.connected || isMobile) { + return null; + } - {activeProjectLabel && ( -
- {activeProjectLabel} -
- )} - - {projectActionsContext && ( - - )} - - {tabs.length > 0 && ( -
- {tabs.map((tab) => renderTab(tab))} -
- )} - -
- -
- {showDesktopHeaderContextUsage && stableDesktopContextUsage && ( - - )} - {showPlanTab && ( - - - - - -

Plan ({shortcutLabel('toggle_context_plan')})

-
-
- )} - - { - setIsDesktopServicesOpen(open); - if (open) { - void refreshCurrentInstanceLabel(); - if (desktopServicesTab === 'usage' && quotaResults.length === 0) { - fetchAllQuotas(); - } - } - }} - > - - - - - - - -

- {isDesktopApp - ? `Current instance: ${currentInstanceLabel}` - : 'Services'} ({shortcutLabel('toggle_services_menu')}; next tab {shortcutLabel('cycle_services_tab')}) -

-
-
- -
-
- { - const value = tabID as 'instance' | 'usage' | 'mcp'; - setDesktopServicesTab(value); - if (value === 'usage' && quotaResults.length === 0) { - fetchAllQuotas(); - } - }} - layoutMode="fit" - variant="active-pill" - activePillInsetClassName="gap-0.5 px-px py-0" - activePillButtonClassName="h-8" - className="h-full" - /> -
-
- - {isDesktopApp && desktopServicesTab === 'instance' && ( - {}} - onHostSwitched={() => setIsDesktopServicesOpen(false)} - /> - )} - - {desktopServicesTab === 'mcp' && ( - - )} - - {desktopServicesTab === 'usage' && ( -
-
- -
- Rate limits - - Last updated {formatTime(quotaLastUpdated)} - -
-
-
- handleDisplayModeChange(tabID as 'usage' | 'remaining')} - layoutMode="fit" - variant="active-pill" - activePillInsetClassName="gap-0.5 px-px py-0" - className="h-full" - /> -
- -
-
-
- {!hasRateLimits && ( - event.preventDefault()} - > - No rate limits available. - - )} - {rateLimitGroups.map((group, index) => { - const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; - - return ( - - - - {group.providerName} - - - {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? ( - event.preventDefault()} - > - - {group.error ?? 'No rate limits reported.'} - - - ) : ( - <> - {group.entries.map(([label, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); - const expectedMarker = paceInfo?.dailyAllocationPercent != null - ? (quotaDisplayMode === 'remaining' - ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) - : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) - : null; - return ( - event.preventDefault()} - > - - - - {formatWindowLabel(label)} - {(window.resetAfterFormatted ?? window.resetAtFormatted) ? ( - - {window.resetAfterFormatted ?? window.resetAtFormatted} - - ) : null} - - - {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} - - - - {paceInfo && ( -
- -
- )} -
-
- ); - })} - - {group.modelFamilies && group.modelFamilies.length > 0 && ( -
- {group.modelFamilies.map((family) => { - const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other'); - - return ( - toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')} - > - - - {family.familyLabel} - - {isExpanded ? ( - - ) : ( - - )} - - -
- {family.models.map(([modelName, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds); - const expectedMarker = paceInfo?.dailyAllocationPercent != null - ? (quotaDisplayMode === 'remaining' - ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) - : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) - : null; - return ( -
-
- - {getDisplayModelName(modelName)} - - {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} - - - - {paceInfo && ( -
- -
- )} -
-
- ); - })} -
-
-
- ); - })} -
- )} - - )} - {index < rateLimitGroups.length - 1 && } -
- ); - })} -
- )} -
-
- - + if (githubAccounts.length > 1) { + return ( + + - - -

Terminal panel ({shortcutLabel('toggle_terminal')})

-
-
- - - - - - -

Right sidebar ({shortcutLabel('toggle_right_sidebar')})

-
-
- - {githubAuthStatus?.connected && !isMobile ? ( - githubAccounts.length > 1 ? ( - - - - - - - GitHub Accounts - - - {githubAccounts.map((account) => { - const accountUser = account.user; - const isCurrent = Boolean(account.current); - return ( - { - if (!isCurrent) { - void handleGitHubAccountSwitch(account.id); - } - }} - > - {accountUser?.avatarUrl ? ( - {accountUser.login - ) : ( -
- -
- )} - - - {accountUser?.name?.trim() || accountUser?.login || 'GitHub'} - - {accountUser?.login ? ( - - {accountUser.login} - - ) : null} - - {isCurrent ? ( - - ) : null} -
- ); - })} -
-
- ) : ( -
{githubAvatarUrl ? ( = ({ ) : ( )} + + + + + GitHub Accounts + + + {githubAccounts.map((account) => { + const accountUser = account.user; + const isCurrent = Boolean(account.current); + return ( + { + if (!isCurrent) { + void handleGitHubAccountSwitch(account.id); + } + }} + > + {accountUser?.avatarUrl ? ( + {accountUser.login + ) : ( +
+ +
+ )} + + + {accountUser?.name?.trim() || accountUser?.login || 'GitHub'} + + {accountUser?.login ? ( + + {accountUser.login} + + ) : null} + + {isCurrent ? : null} +
+ ); + })} +
+ + ); + } + + return ( +
+ {githubAvatarUrl ? ( + {githubLogin + ) : ( + + )} +
+ ); + }; + + const desktopSidebarActions = ( + <> + {showPlanTab && ( + + + + + +

Plan ({shortcutLabel('toggle_context_plan')})

+
+
+ )} + + { + setIsDesktopServicesOpen(open); + if (open) { + void refreshCurrentInstanceLabel(); + if (desktopServicesTab === 'usage' && quotaResults.length === 0) { + fetchAllQuotas(); + } + } + }} + > + + + + + + + +

+ {isDesktopApp ? `Current instance: ${currentInstanceLabel}` : 'Services'} ({shortcutLabel('toggle_services_menu')}; next tab {shortcutLabel('cycle_services_tab')}) +

+
+
+ +
+
+ { + const value = tabID as 'instance' | 'usage' | 'mcp'; + setDesktopServicesTab(value); + if (value === 'usage' && quotaResults.length === 0) { + fetchAllQuotas(); + } + }} + layoutMode="fit" + variant="active-pill" + activePillInsetClassName="gap-0.5 px-px py-0" + activePillButtonClassName="h-8" + className="h-full" + />
- ) +
+ + {isDesktopApp && desktopServicesTab === 'instance' ? ( + {}} + onHostSwitched={() => setIsDesktopServicesOpen(false)} + /> + ) : null} + + {desktopServicesTab === 'mcp' ? ( + + ) : null} + + {desktopServicesTab === 'usage' ? ( +
+ {/* Usage header bar */} +
+
+ Rate limits + {formatTime(quotaLastUpdated)} +
+
+
+ handleDisplayModeChange(tabID as 'usage' | 'remaining')} + layoutMode="fit" + variant="active-pill" + activePillInsetClassName="gap-0.5 px-px py-0" + className="h-full" + /> +
+ +
+
+ + {!hasRateLimits ? ( +
+ No rate limits available. +
+ ) : null} + + {/* Provider groups */} +
+ {rateLimitGroups.map((group, index) => { + const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; + + return ( + + {index > 0 ? ( +
+ ) : null} + + {/* Provider header */} +
+ + {group.providerName} +
+ + {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? ( +
+ {group.error ?? 'No rate limits reported.'} +
+ ) : ( +
+ {/* Window-level entries */} + {group.entries.map(([label, window]) => { + const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent; + const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); + const expectedMarker = paceInfo?.dailyAllocationPercent != null + ? (quotaDisplayMode === 'remaining' + ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) + : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) + : null; + + return ( +
+
+
+ {formatWindowLabel(label)} + {window.resetAfterFormatted ?? window.resetAtFormatted ? ( + + {window.resetAfterFormatted ?? window.resetAtFormatted} + + ) : null} +
+ + {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} + +
+ + {paceInfo ? ( + + ) : null} +
+ ); + })} + + {/* Model family collapsibles */} + {group.modelFamilies && group.modelFamilies.length > 0 ? ( +
+ {group.modelFamilies.map((family) => { + const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other'); + + return ( + toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')} + > + + {family.familyLabel} + {isExpanded ? ( + + ) : ( + + )} + + +
+ {family.models.map(([modelName, window]) => { + const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent; + const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds); + const expectedMarker = paceInfo?.dailyAllocationPercent != null + ? (quotaDisplayMode === 'remaining' + ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) + : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) + : null; + + return ( +
+
+ {getDisplayModelName(modelName)} + + {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} + +
+ + {paceInfo ? ( + + ) : null} +
+ ); + })} +
+
+
+ ); + })} +
+ ) : null} +
+ )} + + ); + })} +
+
+ ) : null} + + + + + + + +

Terminal panel ({shortcutLabel('toggle_terminal')})

+
+
+ + + + + +

Right sidebar ({shortcutLabel('toggle_right_sidebar')})

+
+
+ {renderDesktopGitHubControl()} + + ); + + const desktopSidebarActionsInline = !isRightSidebarOpen || !desktopRightSidebarActionsHost; + + const renderDesktop = () => ( +
+ {!isSidebarOpen ? ( + + + + + +

Open sessions ({shortcutLabel('toggle_sidebar')})

+
+
+ ) : null} + +
+ {!isLeftSidebarOpen ? ( + + + + + +

New session ({shortcutLabel('new_chat')})

+
+
) : null} + {projectActionsContext && ( + + )} + {!isNewSessionDraftOpen ? ( +
+
+ {currentSessionTitle} +
+ {(activeProjectLabel || currentBranchLabel || currentSessionPrLabel || hasNonZeroSessionChanges) ? ( +
+ {activeProjectLabel ? {activeProjectLabel} : null} + {currentBranchLabel ? ( + + + {currentBranchLabel} + + ) : null} + {currentSessionPrLabel ? {currentSessionPrLabel} : null} + {hasNonZeroSessionChanges ? ( + + +{currentSessionChanges.additions} + / + -{currentSessionChanges.deletions} + + ) : null} +
+ ) : null} +
+ ) : null} + + {tabs.length > 0 && ( +
+ {tabs.map((tab) => renderTab(tab))} +
+ )} + +
+ +
+ {showDesktopHeaderContextUsage && stableDesktopContextUsage ? ( + + ) : null} + {desktopSidebarActionsInline ? desktopSidebarActions : null} + {!desktopSidebarActionsInline && desktopRightSidebarActionsHost + ? createPortal(desktopSidebarActions, desktopRightSidebarActionsHost) + : null} +
); @@ -1654,7 +1923,7 @@ export const Header: React.FC = ({ className="h-dvh w-[100vw] max-h-none rounded-none border-0 p-0 overflow-hidden" >
-
+
= ({ {mobileServicesTab === 'usage' && (
-
-
-
+ {/* Mobile usage header */} +
+
+
Rate limits - - Last updated {formatTime(quotaLastUpdated)} + + {formatTime(quotaLastUpdated)}
- {/* Light-weight text toggle for Used/Remaining */}
- {!hasRateLimits && ( - event.preventDefault()} - > - No rate limits available. - - )} - {rateLimitGroups.map((group) => ( - - - - {group.providerName} - - {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? ( - event.preventDefault()} - > - - {group.error ?? 'No rate limits reported.'} - - - ) : ( - <> - {group.entries.map(([label, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); - const expectedMarker = paceInfo?.dailyAllocationPercent != null - ? (quotaDisplayMode === 'remaining' - ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) - : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) - : null; - return ( - event.preventDefault()} - > - - - + {!hasRateLimits && ( +
+ No rate limits available. +
+ )} + + {/* Mobile provider groups */} +
+ {rateLimitGroups.map((group, index) => ( + + {index > 0 ? ( +
+ ) : null} + + {/* Provider header */} +
+ + {group.providerName} +
+ + {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? ( +
+ + {group.error ?? 'No rate limits reported.'} + +
+ ) : ( +
+ {/* Window-level entries */} + {group.entries.map(([label, window]) => { + const displayPercent = quotaDisplayMode === 'remaining' + ? window.remainingPercent + : window.usedPercent; + const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); + const expectedMarker = paceInfo?.dailyAllocationPercent != null + ? (quotaDisplayMode === 'remaining' + ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) + : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) + : null; + return ( +
+
+
{formatWindowLabel(label)} {(window.resetAfterFormatted ?? window.resetAtFormatted) ? ( - + {window.resetAfterFormatted ?? window.resetAtFormatted} ) : null} - +
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} - +
- {paceInfo && ( -
- -
- )} - - - ); - })} + {paceInfo ? ( + + ) : null} +
+ ); + })} - {group.modelFamilies && group.modelFamilies.length > 0 && ( -
- {group.modelFamilies.map((family) => { - const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; - const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other'); + {/* Model family collapsibles */} + {group.modelFamilies && group.modelFamilies.length > 0 && ( +
+ {group.modelFamilies.map((family) => { + const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; + const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other'); - return ( - toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')} - > - - - {family.familyLabel} - - {isExpanded ? ( - - ) : ( - - )} - - -
- {family.models.map(([modelName, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds); - const expectedMarker = paceInfo?.dailyAllocationPercent != null - ? (quotaDisplayMode === 'remaining' - ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) - : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) - : null; - return ( -
-
- + return ( + toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')} + > + + + {family.familyLabel} + + {isExpanded ? ( + + ) : ( + + )} + + +
+ {family.models.map(([modelName, window]) => { + const displayPercent = quotaDisplayMode === 'remaining' + ? window.remainingPercent + : window.usedPercent; + const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds); + const expectedMarker = paceInfo?.dailyAllocationPercent != null + ? (quotaDisplayMode === 'remaining' + ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) + : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) + : null; + return ( +
+
{getDisplayModelName(modelName)} {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} - +
- {paceInfo && ( -
- -
- )} + {paceInfo ? ( + + ) : null}
-
- ); - })} -
- - - ); - })} -
- )} - - )} - - ))} + ); + })} +
+
+
+ ); + })} +
+ )} +
+ )} + + ))} +
)}
@@ -1925,7 +2184,9 @@ export const Header: React.FC = ({ const headerClassName = cn( 'header-safe-area relative z-10', isMobile && 'border-b border-border/50', - isDesktopApp ? 'bg-background' : 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80' + isMobile + ? 'bg-background' + : (isDesktopApp ? 'bg-background' : 'bg-background/95 supports-[backdrop-filter]:bg-background/80') ); return ( diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index be0480b8..505506da 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -2,9 +2,8 @@ import React, { useRef, useEffect } from 'react'; import { motion, useMotionValue, animate } from 'motion/react'; import { Header } from './Header'; import { BottomTerminalDock } from './BottomTerminalDock'; -import { Sidebar } from './Sidebar'; -import { NavRail } from './NavRail'; -import { RightSidebar } from './RightSidebar'; +import { Sidebar, SIDEBAR_CONTENT_WIDTH } from './Sidebar'; +import { RightSidebar, RIGHT_SIDEBAR_CONTENT_WIDTH } from './RightSidebar'; import { RightSidebarTabs } from './RightSidebarTabs'; import { ContextPanel } from './ContextPanel'; import { ErrorBoundary } from '../ui/ErrorBoundary'; @@ -22,11 +21,16 @@ import { useUpdateStore } from '@/stores/useUpdateStore'; import { useDeviceInfo } from '@/lib/device'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { cn } from '@/lib/utils'; +import { isDesktopShell } from '@/lib/desktop'; import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views'; // Mobile drawer width as screen percentage const MOBILE_DRAWER_WIDTH_PERCENT = 85; +const DESKTOP_SIDEBAR_MIN_WIDTH = 250; +const DESKTOP_SIDEBAR_MAX_WIDTH = 500; +const DESKTOP_RIGHT_SIDEBAR_MIN_WIDTH = 400; +const DESKTOP_RIGHT_SIDEBAR_MAX_WIDTH = 860; const normalizeDirectoryKey = (value: string): string => { if (!value) return ''; @@ -69,6 +73,10 @@ export const MainLayout: React.FC = () => { } = useUIStore(); const { isMobile } = useDeviceInfo(); + const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []); + const sidebarWidth = useUIStore((state) => state.sidebarWidth); + const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth); + const [desktopRightSidebarActionsHost, setDesktopRightSidebarActionsHost] = React.useState(null); const effectiveDirectory = useEffectiveDirectory() ?? ''; const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]); const isContextPanelOpen = useUIStore((state) => { @@ -590,6 +598,14 @@ export const MainLayout: React.FC = () => { }, [activeMainTab]); const isChatActive = activeMainTab === 'chat'; + const visibleSidebarWidth = React.useMemo(() => { + const rawWidth = sidebarWidth || SIDEBAR_CONTENT_WIDTH; + return Math.min(DESKTOP_SIDEBAR_MAX_WIDTH, Math.max(DESKTOP_SIDEBAR_MIN_WIDTH, rawWidth)); + }, [sidebarWidth]); + const visibleRightSidebarWidth = React.useMemo(() => { + const rawWidth = rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH; + return Math.min(DESKTOP_RIGHT_SIDEBAR_MAX_WIDTH, Math.max(DESKTOP_RIGHT_SIDEBAR_MIN_WIDTH, rawWidth)); + }, [rightSidebarWidth]); return ( @@ -597,7 +613,7 @@ export const MainLayout: React.FC = () => { className={cn( 'main-content-safe-area h-[100dvh]', isMobile ? 'flex flex-col' : 'flex', - 'bg-background' + isDesktopShellRuntime ? 'bg-transparent' : 'bg-background' )} > @@ -654,7 +670,7 @@ export const MainLayout: React.FC = () => { opacity: mobileLeftDrawerOpen || isRightSidebarOpen ? 1 : 0, pointerEvents: mobileLeftDrawerOpen || isRightSidebarOpen ? 'auto' : 'none', }} - className="fixed inset-0 z-40 bg-black/50 cursor-default" + className="fixed left-0 right-0 bottom-0 top-[var(--oc-header-height,56px)] z-40 bg-black/50 cursor-default" onClick={() => { setMobileLeftDrawerOpen(false); setRightSidebarOpen(false); @@ -696,15 +712,15 @@ export const MainLayout: React.FC = () => { } }} className={cn( - 'fixed left-0 top-0 z-50 h-full bg-transparent', + 'fixed left-0 top-[var(--oc-header-height,56px)] z-50 h-[calc(100%-var(--oc-header-height,56px))] bg-transparent', 'cursor-grab active:cursor-grabbing' )} aria-hidden={!mobileLeftDrawerOpen} > -
-
e.stopPropagation()}> - -
+
@@ -747,7 +763,7 @@ export const MainLayout: React.FC = () => { } }} className={cn( - 'fixed right-0 top-0 z-50 h-full bg-transparent', + 'fixed right-0 top-[var(--oc-header-height,56px)] z-50 h-[calc(100%-var(--oc-header-height,56px))] bg-transparent', 'cursor-grab active:cursor-grabbing' )} aria-hidden={!isRightSidebarOpen} @@ -765,7 +781,6 @@ export const MainLayout: React.FC = () => { 'flex flex-1 overflow-hidden relative', (isSettingsDialogOpen || isMultiRunLauncherOpen) && 'hidden' )} - style={{ paddingTop: 'var(--oc-header-height, 56px)' }} >
@@ -781,7 +796,10 @@ export const MainLayout: React.FC = () => { {/* Mobile multi-run launcher: full screen */} {isMultiRunLauncherOpen && ( -
+
{ {/* Mobile settings: full screen */} {isSettingsDialogOpen && ( -
+
setSettingsDialogOpen(false)} />
)} ) : ( <> - {/* Desktop: Header always on top, then Sidebar + Content below */} -
- {/* Normal view: Header above Sidebar + content (like SettingsView) */} -
-
-
- -
- - - -
-
-
-
-
- + {/* Desktop: Sidebar is a left column; header belongs to content column */} +
+
+ {isSidebarOpen ? ( + <> +
+
+ + ) : null} + {isRightSidebarOpen ? ( + <> +
+
+ + ) : null} + + + +
+
+
+
+
+
+ +
+ {secondaryView && ( +
+ {secondaryView}
- {secondaryView && ( -
- {secondaryView} -
- )} -
- -
- - - + )} +
+
- - - -
+ + +
+ + +
{/* Multi-Run Launcher: replaces tabs content only */} diff --git a/packages/ui/src/components/layout/NavRail.tsx b/packages/ui/src/components/layout/NavRail.tsx deleted file mode 100644 index fd8bf942..00000000 --- a/packages/ui/src/components/layout/NavRail.tsx +++ /dev/null @@ -1,919 +0,0 @@ -import React from 'react'; -import { - DndContext, - closestCenter, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, - type Modifier, -} from '@dnd-kit/core'; -import { - SortableContext, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { - RiFolderAddLine, - RiSettings3Line, - RiQuestionLine, - RiDownloadLine, - RiInformationLine, - RiPencilLine, - RiCloseLine, - RiMenuFoldLine, - RiMenuUnfoldLine, -} from '@remixicon/react'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { toast } from '@/components/ui'; - -import { UpdateDialog } from '@/components/ui/UpdateDialog'; -import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog'; -import { useUIStore } from '@/stores/useUIStore'; -import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useSessionStore } from '@/stores/useSessionStore'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { useUpdateStore } from '@/stores/useUpdateStore'; -import { cn, formatDirectoryName, hasModifier } from '@/lib/utils'; -import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta'; -import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, requestDirectoryAccess } from '@/lib/desktop'; -import { useLongPress } from '@/hooks/useLongPress'; -import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; -import { sessionEvents } from '@/lib/sessionEvents'; -import { useThemeSystem } from '@/contexts/useThemeSystem'; -import type { ProjectEntry } from '@/lib/api/types'; - -const normalize = (value: string): string => { - if (!value) return ''; - const replaced = value.replace(/\\/g, '/'); - return replaced === '/' ? '/' : replaced.replace(/\/+$/, ''); -}; - -const NAV_RAIL_WIDTH = 56; -const NAV_RAIL_EXPANDED_WIDTH = 200; -const NAV_RAIL_TEXT_FADE_MS = 180; -const PROJECT_TEXT_FADE_IN_DELAY_MS = 24; -const ACTION_TEXT_FADE_IN_DELAY_MS = 60; - -type NavRailActionButtonProps = { - onClick: () => void; - disabled?: boolean; - ariaLabel: string; - icon: React.ReactNode; - tooltipLabel: string; - shortcutHint?: string; - showExpandedShortcutHint?: boolean; - buttonClassName: string; - showExpandedContent: boolean; - actionTextVisible: boolean; -}; - -const NavRailActionButton: React.FC = ({ - onClick, - disabled = false, - ariaLabel, - icon, - tooltipLabel, - shortcutHint, - showExpandedShortcutHint = true, - buttonClassName, - showExpandedContent, - actionTextVisible, -}) => { - const pointerTriggeredRef = React.useRef(false); - const pointerPressRef = React.useRef<{ active: boolean; pointerId: number | null }>({ - active: false, - pointerId: null, - }); - - const handlePointerDown = React.useCallback((event: React.PointerEvent) => { - if (disabled || event.button !== 0) { - pointerPressRef.current = { active: false, pointerId: null }; - return; - } - pointerPressRef.current = { active: true, pointerId: event.pointerId }; - }, [disabled]); - - const clearPointerPress = React.useCallback(() => { - pointerPressRef.current = { active: false, pointerId: null }; - }, []); - - const handlePointerUp = React.useCallback((event: React.PointerEvent) => { - if (disabled) return; - if (event.button !== 0) return; - const pointerPress = pointerPressRef.current; - if (!pointerPress.active || pointerPress.pointerId !== event.pointerId) { - return; - } - clearPointerPress(); - pointerTriggeredRef.current = true; - onClick(); - }, [clearPointerPress, disabled, onClick]); - - const handleClick = React.useCallback(() => { - if (disabled) return; - if (pointerTriggeredRef.current) { - pointerTriggeredRef.current = false; - return; - } - onClick(); - }, [disabled, onClick]); - - const btn = ( - - ); - - return ( - - {btn} - {!showExpandedContent && ( - -

{shortcutHint ? `${tooltipLabel} (${shortcutHint})` : tooltipLabel}

-
- )} -
- ); -}; - -/** Tinted background for project tiles — uses project color at low opacity, or neutral fallback */ -const TileBackground: React.FC<{ colorVar: string | null; children: React.ReactNode }> = ({ - colorVar, - children, -}) => ( - - {colorVar && ( - - )} - - {children} - - -); - -/** First-letter avatar fallback */ -const LetterAvatar: React.FC<{ label: string; color?: string | null }> = ({ - label, - color, -}) => { - const letter = label.charAt(0).toUpperCase() || '?'; - const colorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null; - return ( - - {letter} - - ); -}; - -const ProjectStatusDots: React.FC<{ - color: string; - variant?: 'streaming' | 'attention' | 'none'; - size?: 'sm' | 'md'; -}> = ({ color, variant = 'none', size = 'md' }) => ( -
@@ -213,9 +211,9 @@ export const GitPage: React.FC = () => { - void handleConfirmDelete()} disabled={isDeletePending} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0"> + @@ -340,10 +338,10 @@ const DiscoveredRow: React.FC = ({ credential, onImport, has {isRepoSpecific ? credential.host : credential.username}
- +
); }; diff --git a/packages/ui/src/components/sections/mcp/McpPage.tsx b/packages/ui/src/components/sections/mcp/McpPage.tsx index ee145fe4..8901d3c4 100644 --- a/packages/ui/src/components/sections/mcp/McpPage.tsx +++ b/packages/ui/src/components/sections/mcp/McpPage.tsx @@ -1,7 +1,5 @@ import React from 'react'; import { Button } from '@/components/ui/button'; -import { ButtonLarge } from '@/components/ui/button-large'; -import { ButtonSmall } from '@/components/ui/button-small'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; @@ -110,7 +108,7 @@ const CommandTextarea: React.FC = ({ value, onChange }) => return (
- = ({ value, onChange }) => > Paste command - +