feat: vscode extension (#59)

* feat: add initial VS Code extension plan and implementation tasks

* feat(vscode): added initial version of an Openchamber VSCode extension

* feat(vscode): enhance VS Code extension with theme integration and session management

* feat(vscode): implement connection status handling and overlay in VSCode layout

* feat: move extension to secondary sidebar

* chore: upgrade @opencode-ai/sdk to 1.0.150

* vscode: editor bridge, file picker, click-to-open in tool parts

* vscode: layout session lifecycle, theme sync, typography overrides

* ui: compact mode for vscode, model search, autocomplete width fixes

* perf: scroll force flag, raf placeholder, git polling backoff

* ui: tool output styling, markdown code block fix, gitignore

* refactor: update typography handling for VSCode runtime, remove unused styles

* docs: update README with VS Code extension details and add extension image

* docs: update changelog with new features and performance improvements
This commit is contained in:
Bohdan Triapitsyn
2025-12-13 16:34:17 +02:00
committed by GitHub
parent 610ccf4c62
commit bb72c0fb0c
76 changed files with 6097 additions and 296 deletions
+20 -1
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { MainLayout } from '@/components/layout/MainLayout';
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
import { FireworksProvider } from '@/contexts/FireworksContext';
import { Toaster } from '@/components/ui/sonner';
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
@@ -34,6 +35,7 @@ function App({ apis }: AppProps) {
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
const { uiFont, monoFont } = useFontPreferences();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => apis.runtime.isDesktop);
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
const [cliAvailable, setCliAvailable] = React.useState<boolean>(() => {
if (!apis.runtime.isDesktop) return true;
return isCliAvailable();
@@ -41,7 +43,8 @@ function App({ apis }: AppProps) {
React.useEffect(() => {
setIsDesktopRuntime(apis.runtime.isDesktop);
}, [apis.runtime.isDesktop]);
setIsVSCodeRuntime(apis.runtime.isVSCode);
}, [apis.runtime.isDesktop, apis.runtime.isVSCode]);
React.useEffect(() => {
registerRuntimeAPIs(apis);
@@ -165,6 +168,22 @@ function App({ apis }: AppProps) {
);
}
// VS Code runtime - simplified layout without git/terminal views
if (isVSCodeRuntime) {
return (
<ErrorBoundary>
<RuntimeAPIProvider apis={apis}>
<FireworksProvider>
<div className="h-full text-foreground bg-background">
<VSCodeLayout />
<Toaster />
</div>
</FireworksProvider>
</RuntimeAPIProvider>
</ErrorBoundary>
);
}
return (
<ErrorBoundary>
<RuntimeAPIProvider apis={apis}>
@@ -2,7 +2,7 @@ import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { OpenCodeIcon } from '@/components/ui/OpenCodeIcon';
import { isDesktopRuntime } from '@/lib/desktop';
import { isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
@@ -89,15 +89,17 @@ type GateState = 'pending' | 'authenticated' | 'locked' | 'error';
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []);
const [state, setState] = React.useState<GateState>(() => (desktopRuntime ? 'authenticated' : 'pending'));
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
const skipAuth = desktopRuntime || vscodeRuntime;
const [state, setState] = React.useState<GateState>(() => (skipAuth ? 'authenticated' : 'pending'));
const [password, setPassword] = React.useState('');
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState('');
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
const hasResyncedRef = React.useRef(desktopRuntime);
const hasResyncedRef = React.useRef(skipAuth);
const checkStatus = React.useCallback(async () => {
if (desktopRuntime) {
if (skipAuth) {
setState('authenticated');
return;
}
@@ -119,20 +121,20 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
console.warn('Failed to check session status:', error);
setState('error');
}
}, [desktopRuntime]);
}, [skipAuth]);
React.useEffect(() => {
if (desktopRuntime) {
if (skipAuth) {
return;
}
void checkStatus();
}, [checkStatus, desktopRuntime]);
}, [checkStatus, skipAuth]);
React.useEffect(() => {
if (!desktopRuntime && state === 'locked') {
if (!skipAuth && state === 'locked') {
hasResyncedRef.current = false;
}
}, [desktopRuntime, state]);
}, [skipAuth, state]);
React.useEffect(() => {
if (state === 'locked' && passwordInputRef.current) {
@@ -142,7 +144,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
}, [state]);
React.useEffect(() => {
if (desktopRuntime) {
if (skipAuth) {
return;
}
if (state === 'authenticated' && !hasResyncedRef.current) {
@@ -153,7 +155,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
await applyPersistedDirectoryPreferences();
})();
}
}, [desktopRuntime, state]);
}, [skipAuth, state]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -129,7 +129,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
return (
<div
ref={containerRef}
className="absolute z-[100] min-w-[240px] max-w-[360px] max-h-60 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 w-max flex flex-col"
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
{agents.length ? (
@@ -48,9 +48,9 @@ export const ChatContainer: React.FC = () => {
const {
scrollRef,
handleMessageContentChange,
getAnimationHandlers,
showScrollButton,
scrollToBottom,
getAnimationHandlers,
showScrollButton,
scrollToBottom,
spacerHeight,
pendingAnchorId,
hasActiveAnchor,
@@ -179,7 +179,7 @@ export const ChatContainer: React.FC = () => {
if (sessionMessages.length === 0 && !streamingMessageId) {
return (
<div className="flex flex-col h-full bg-background">
<div className="flex flex-col h-full bg-background transform-gpu">
<div className="flex-1 flex items-center justify-center">
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
</div>
@@ -239,7 +239,7 @@ export const ChatContainer: React.FC = () => {
<Button
variant="outline"
size="sm"
onClick={() => scrollToBottom()}
onClick={() => scrollToBottom({ force: true })}
className="rounded-full h-8 w-8 p-0 shadow-none bg-background/95 hover:bg-accent"
aria-label="Scroll to bottom"
>
@@ -24,7 +24,7 @@ const MAX_VISIBLE_TEXTAREA_LINES = 8;
interface ChatInputProps {
onOpenSettings?: () => void;
scrollToBottom?: (options?: { instant?: boolean }) => void;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
}
const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null;
@@ -145,7 +145,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const messageToSend = message.replace(/^\n+|\n+$/g, '');
scrollToBottom?.({ instant: true });
scrollToBottom?.({ instant: true, force: true });
const normalizedCommand = messageToSend.trimStart();
if (normalizedCommand.startsWith('/')) {
@@ -155,7 +155,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
.split(/\s+/)[0]
?.toLowerCase();
if (commandName === 'summarize') {
scrollToBottom?.({ instant: true });
scrollToBottom?.({ instant: true, force: true });
}
}
@@ -58,7 +58,7 @@ interface ChatMessageProps {
};
onContentChange?: (reason?: ContentChangeReason) => void;
animationHandlers?: AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean }) => void;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
isPendingAnchor?: boolean;
turnGroupingContext?: TurnGroupingContext;
}
@@ -201,7 +201,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
return (
<div
ref={containerRef}
className="absolute z-[100] min-w-[250px] max-w-[450px] max-h-64 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 w-max flex flex-col"
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
{loading ? (
@@ -5,24 +5,24 @@ import { useUIStore } from '@/stores/useUIStore';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import type { ToolPopupContent } from './message/types';
export const FileAttachmentButton = memo(() => {
const fileInputRef = useRef<HTMLInputElement>(null);
const { addAttachedFile } = useSessionStore();
const { isMobile } = useUIStore();
const isVSCodeRuntime = useIsVSCodeRuntime();
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
const attachFiles = async (files: FileList | File[]) => {
let attachedCount = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const sizeBefore = useSessionStore.getState().attachedFiles.length;
try {
await addAttachedFile(files[i]);
await addAttachedFile(file);
const sizeAfter = useSessionStore.getState().attachedFiles.length;
if (sizeAfter > sizeBefore) {
attachedCount++;
@@ -32,16 +32,63 @@ export const FileAttachmentButton = memo(() => {
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
}
}
if (attachedCount > 0) {
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
}
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
await attachFiles(files);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleVSCodePick = async () => {
try {
const response = await fetch('/api/vscode/pick-files');
const data = await response.json();
const picked = Array.isArray(data?.files) ? data.files : [];
const skipped = Array.isArray(data?.skipped) ? data.skipped : [];
if (skipped.length > 0) {
const summary = skipped.map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`).join('\n');
toast.error(`Some files were skipped:\n${summary}`);
}
const asFiles = picked
.map((file: { name: string; mimeType?: string; dataUrl?: string }) => {
if (!file?.dataUrl) return null;
try {
const [meta, base64] = file.dataUrl.split(',');
const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream');
if (!base64) return null;
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mime });
return new File([blob], file.name || 'file', { type: mime });
} catch (err) {
console.error('Failed to decode VS Code picked file', err);
return null;
}
})
.filter(Boolean) as File[];
if (asFiles.length > 0) {
await attachFiles(asFiles);
}
} catch (error) {
console.error('VS Code file pick failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code');
}
};
return (
<>
<input
@@ -54,7 +101,13 @@ export const FileAttachmentButton = memo(() => {
/>
<button
type='button'
onClick={() => fileInputRef.current?.click()}
onClick={() => {
if (isVSCodeRuntime) {
void handleVSCodePick();
} else {
fileInputRef.current?.click();
}
}}
className={cn(
buttonSizeClass,
'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0'
@@ -164,7 +164,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return (
<div
ref={containerRef}
className="absolute z-[100] min-w-[240px] max-w-[520px] max-h-64 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 w-max flex flex-col"
className="absolute z-[100] min-w-0 w-full max-w-[520px] max-h-64 bg-popover border border-border rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
{loading ? (
@@ -235,7 +235,11 @@ const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }>
);
};
const CodeBlockWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
type CodeBlockWrapperProps = React.HTMLAttributes<HTMLPreElement> & {
children?: React.ReactNode;
};
const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className, style, ...props }) => {
const [copied, setCopied] = React.useState(false);
const codeRef = React.useRef<HTMLDivElement>(null);
@@ -259,8 +263,18 @@ const CodeBlockWrapper: React.FC<{ children?: React.ReactNode; className?: strin
};
return (
<div className={cn('group relative', className)} ref={codeRef}>
{children}
<div className="group relative" ref={codeRef}>
<pre
{...props}
className={cn(className)}
style={{
...style,
background: 'transparent',
backgroundColor: 'transparent',
}}
>
{children}
</pre>
<div className="absolute top-1 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={handleCopy}
@@ -16,7 +16,7 @@ interface MessageListProps {
hasMoreAbove: boolean;
isLoadingOlder: boolean;
onLoadOlder: () => void;
scrollToBottom?: (options?: { instant?: boolean }) => void;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
pendingAnchorId?: string | null;
}
+110 -53
View File
@@ -11,7 +11,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiCheckboxCircleLine, RiCloseCircleLine, RiFileImageLine, RiFileMusicLine, RiFilePdfLine, RiFileVideoLine, RiPencilAiLine, RiQuestionLine, RiText, RiToolsLine } from '@remixicon/react';
import { Input } from '@/components/ui/input';
import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiCheckboxCircleLine, RiCloseCircleLine, RiFileImageLine, RiFileMusicLine, RiFilePdfLine, RiFileVideoLine, RiPencilAiLine, RiQuestionLine, RiSearchLine, RiText, RiToolsLine } from '@remixicon/react';
import type { ModelMetadata } from '@/types';
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
import { getEditModeColors } from '@/lib/permissions/editModeColors';
@@ -25,6 +26,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useContextStore } from '@/stores/contextStore';
import { useDeviceInfo } from '@/lib/device';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { cn } from '@/lib/utils';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { getAgentColor } from '@/lib/agentColors';
@@ -214,8 +216,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const contextHydrated = useContextStore((state) => state.hasHydrated);
const { isMobile } = useDeviceInfo();
const isVSCodeRuntime = useIsVSCodeRuntime();
const isCompact = isMobile || isVSCodeRuntime;
const [activeMobilePanel, setActiveMobilePanel] = React.useState<'model' | 'agent' | null>(null);
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState<'model' | 'agent' | null>(null);
const [mobileModelQuery, setMobileModelQuery] = React.useState('');
const closeMobilePanel = React.useCallback(() => setActiveMobilePanel(null), []);
const closeMobileTooltip = React.useCallback(() => setMobileTooltipOpen(null), []);
const longPressTimerRef = React.useRef<NodeJS.Timeout | undefined>(undefined);
@@ -247,6 +252,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
if (activeMobilePanel !== 'agent') {
setMobileEditOptionsOpen(false);
}
if (activeMobilePanel !== 'model') {
setMobileModelQuery('');
}
}, [activeMobilePanel]);
React.useEffect(() => {
@@ -321,11 +329,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
}
}, [editToggleDisabled]);
const buttonHeight = isMobile ? 'h-9' : 'h-8';
const editToggleIconClass = isMobile ? 'h-5 w-5' : 'h-4 w-4';
const controlIconSize = isMobile ? 'h-5 w-5' : 'h-4 w-4';
const controlTextSize = isMobile ? 'typography-micro' : 'typography-meta';
const inlineGapClass = isMobile ? 'gap-x-2' : 'gap-x-3';
const buttonHeight = isCompact ? 'h-9' : 'h-8';
const editToggleIconClass = isCompact ? 'h-5 w-5' : 'h-4 w-4';
const controlIconSize = isCompact ? 'h-5 w-5' : 'h-4 w-4';
const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
const inlineGapClass = isCompact ? 'gap-x-2' : 'gap-x-3';
const editPermissionMenuLabel = editModeShortLabels[effectiveEditMode];
const renderEditModeIcon = React.useCallback((mode: EditPermissionMode, iconClass = editToggleIconClass) => {
@@ -725,7 +733,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
if (currentSessionId) {
saveSessionAgentSelection(currentSessionId, agentName);
}
if (isMobile) {
if (isCompact) {
closeMobilePanel();
}
} catch (error) {
@@ -744,7 +752,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
}
return;
}
if (isMobile) {
if (isCompact) {
closeMobilePanel();
}
} catch (error) {
@@ -835,7 +843,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
}, []);
const renderMobileModelTooltip = () => {
if (!isMobile || mobileTooltipOpen !== 'model') return null;
if (!isCompact || mobileTooltipOpen !== 'model') return null;
return (
<MobileOverlayPanel
@@ -925,7 +933,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
};
const renderMobileAgentTooltip = () => {
if (!isMobile || mobileTooltipOpen !== 'agent' || !currentAgent) return null;
if (!isCompact || mobileTooltipOpen !== 'agent' || !currentAgent) return null;
const enabledTools = Object.entries(currentAgent.tools || {})
.filter(([, enabled]) => enabled)
@@ -1067,7 +1075,25 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
};
const renderMobileModelPanel = () => {
if (!isMobile) return null;
if (!isCompact) return null;
const normalizedQuery = mobileModelQuery.trim().toLowerCase();
const filteredProviders = providers
.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
const matchesProvider = normalizedQuery.length === 0
? true
: provider.name.toLowerCase().includes(normalizedQuery) || provider.id.toLowerCase().includes(normalizedQuery);
const matchingModels = normalizedQuery.length === 0
? providerModels
: providerModels.filter((model: ProviderModel) => {
const name = getModelDisplayName(model).toLowerCase();
const id = typeof model.id === 'string' ? model.id.toLowerCase() : '';
return name.includes(normalizedQuery) || id.includes(normalizedQuery);
});
return { provider, providerModels: matchingModels, matchesProvider };
})
.filter(({ matchesProvider, providerModels }) => matchesProvider || providerModels.length > 0);
return (
<MobileOverlayPanel
@@ -1075,15 +1101,42 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
onClose={closeMobilePanel}
title="Select model"
>
<div className="space-y-2">
{providers.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
if (providerModels.length === 0) {
<div className="flex flex-col gap-2">
<div className="px-2">
<div className="relative">
<RiSearchLine className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={mobileModelQuery}
onChange={(event) => setMobileModelQuery(event.target.value)}
placeholder="Search providers or models"
className="pl-7 h-8 typography-meta"
/>
{mobileModelQuery && (
<button
type="button"
onClick={() => setMobileModelQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<RiCloseCircleLine className="h-4 w-4" />
</button>
)}
</div>
</div>
{filteredProviders.length === 0 && (
<div className="px-3 py-8 text-center typography-meta text-muted-foreground">
No providers or models match your search.
</div>
)}
{filteredProviders.map(({ provider, providerModels }) => {
if (providerModels.length === 0 && !normalizedQuery.length) {
return null;
}
const isActiveProvider = provider.id === currentProviderId;
const isExpanded = expandedMobileProviders.has(provider.id);
const isExpanded = expandedMobileProviders.has(provider.id) || normalizedQuery.length > 0;
return (
<div key={provider.id} className="rounded-xl border border-border/40 bg-background/95">
@@ -1112,7 +1165,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
)}
</button>
{isExpanded && (
{isExpanded && providerModels.length > 0 && (
<div className="flex flex-col border-t border-border/30">
{providerModels.map((model: ProviderModel) => {
const isSelected = isActiveProvider && model.id === currentModelId;
@@ -1137,29 +1190,29 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
<span className="typography-meta font-medium text-foreground">
{getModelDisplayName(model)}
</span>
<div className="flex flex-wrap items-center gap-1 pt-0.5">
{capabilityIcons.map(({ key, icon: IconComponent, label }) => (
<span
key={`cap-${provider.id}-${model.id}-${key}`}
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
title={label}
aria-label={label}
>
<IconComponent className="h-3 w-3" />
</span>
))}
{inputIcons.map(({ key, icon: IconComponent, label }) => (
<span
key={`input-${provider.id}-${model.id}-${key}`}
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
title={`${label} input`}
aria-label={`${label} input`}
>
<IconComponent className="h-3 w-3" />
</span>
))}
</div>
</div>
<div className="ml-auto flex flex-col items-end gap-1 text-right">
{(metadata?.limit?.context || metadata?.limit?.output) && (
<div className="flex items-center gap-1 typography-micro text-muted-foreground">
{metadata?.limit?.context ? <span>{formatTokens(metadata?.limit?.context)} ctx</span> : null}
{metadata?.limit?.context && metadata?.limit?.output ? <span></span> : null}
{metadata?.limit?.output ? <span>{formatTokens(metadata?.limit?.output)} out</span> : null}
</div>
)}
{(capabilityIcons.length > 0 || inputIcons.length > 0) && (
<div className="flex items-center justify-end gap-1">
{[...capabilityIcons, ...inputIcons].map(({ key, icon: IconComponent, label }) => (
<span
key={`meta-${provider.id}-${model.id}-${key}`}
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
title={label}
aria-label={label}
>
<IconComponent className="h-3 w-3" />
</span>
))}
</div>
)}
</div>
</button>
);
@@ -1175,7 +1228,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
};
const renderMobileAgentPanel = () => {
if (!isMobile) return null;
if (!isCompact) return null;
const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
@@ -1397,13 +1450,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const renderModelSelector = () => (
<Tooltip delayDuration={1000}>
{!isMobile ? (
{!isCompact ? (
<DropdownMenu open={agentMenuOpen} onOpenChange={setAgentMenuOpen}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<div
className={cn(
'flex items-center gap-1.5 cursor-pointer hover:opacity-70 w-fit',
'model-controls__model-trigger flex items-center gap-1.5 cursor-pointer hover:opacity-70 min-w-0 flex-1',
buttonHeight
)}
>
@@ -1420,7 +1473,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
)}
<span
key={`${currentProviderId}-${currentModelId}`}
className={cn(controlTextSize, 'font-medium whitespace-nowrap text-foreground', 'max-w-[32vw]', 'md:max-w-[20vw]', 'truncate')}
className={cn(
'model-controls__model-label',
controlTextSize,
'font-medium whitespace-nowrap text-foreground truncate min-w-0 flex-1'
)}
>
{getCurrentModelDisplayName()}
</span>
@@ -1535,8 +1592,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
onTouchEnd={handleLongPressEnd}
onTouchCancel={handleLongPressEnd}
className={cn(
'flex items-center gap-1.5 min-w-0 focus:outline-none',
'cursor-pointer hover:opacity-70 max-w-full justify-end',
'model-controls__model-trigger flex items-center gap-1.5 min-w-0 focus:outline-none flex-1',
'cursor-pointer hover:opacity-70',
buttonHeight
)}
>
@@ -1548,7 +1605,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
) : (
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
)}
<span className="typography-micro font-medium truncate min-w-0 max-w-[36vw] text-right">
<span className="model-controls__model-label typography-micro font-medium truncate min-w-0 flex-1">
{getCurrentModelDisplayName()}
</span>
</button>
@@ -1697,7 +1754,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
};
const renderAgentSelector = () => {
if (!isMobile) {
if (!isCompact) {
return (
<div className="flex items-center gap-2 min-w-0">
<Tooltip delayDuration={1000}>
@@ -1860,10 +1917,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
onTouchEnd={handleLongPressEnd}
onTouchCancel={handleLongPressEnd}
className={cn(
'flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
'model-controls__agent-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
buttonHeight,
'cursor-pointer hover:opacity-70',
isMobile && 'ml-1'
isCompact && 'ml-1'
)}
>
<RiAiAgentLine
@@ -1875,7 +1932,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
/>
<span
className={cn(controlTextSize, 'font-medium truncate', 'max-w-[36vw]', 'md:max-w-[20vw]')}
className={cn('model-controls__agent-label', controlTextSize, 'font-medium truncate min-w-0')}
style={currentAgentName ? { color: `var(${getAgentColor(currentAgentName).var})` } : undefined}
>
{getAgentDisplayName()}
@@ -1884,12 +1941,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
);
};
const inlineClassName = cn('flex items-center min-w-0', inlineGapClass, className);
const inlineClassName = cn('@container/model-controls flex items-center min-w-0', inlineGapClass, className);
return (
<>
<div className={inlineClassName}>
<div className={cn('flex items-center min-w-0', isMobile ? 'flex-1 min-w-0' : undefined)}>
<div className={cn('flex items-center min-w-0', !isCompact ? 'flex-1 min-w-0' : undefined)}>
{renderModelSelector()}
</div>
<div className={cn('flex items-center min-w-0', inlineGapClass)}>
@@ -17,6 +17,7 @@ import { useDeviceInfo } from '@/lib/device';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
interface FileInfo {
name: string;
@@ -39,6 +40,8 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
children
}) => {
const { isMobile } = useDeviceInfo();
const isVSCodeRuntime = useIsVSCodeRuntime();
const isCompact = isMobile || isVSCodeRuntime;
const { currentDirectory } = useDirectoryStore();
const searchFiles = useFileSearchStore((state) => state.searchFiles);
const [open, setOpen] = React.useState(false);
@@ -331,7 +334,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
: file.name;
const shouldCompact = isSearchActive && rawLabel.includes('/') && rawLabel.length > 45;
const displayLabel = shouldCompact
? truncatePathMiddle(rawLabel, { maxLength: isMobile ? 42 : 48 })
? truncatePathMiddle(rawLabel, { maxLength: isCompact ? 42 : 48 })
: rawLabel;
const row = (
@@ -437,7 +440,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
</div>
);
const scrollAreaClass = isMobile ? 'flex-1 min-h-[240px]' : 'h-[300px]';
const scrollAreaClass = isCompact ? 'flex-1 min-h-[240px]' : 'h-[300px]';
const pickerBody = (
<>
@@ -524,7 +527,7 @@ export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
</span>
);
if (isMobile) {
if (isCompact) {
return (
<>
{mobileTrigger}
@@ -105,7 +105,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
</div>
</div>
<div className="flex-1 min-h-0 rounded-xl border border-border/30 bg-muted/10 overflow-hidden">
<div className="h-full max-h-[75vh] overflow-y-auto px-3 pr-4">
<div className="tool-output-surface h-full max-h-[75vh] overflow-y-auto px-3 pr-4">
{popup.metadata?.input && typeof popup.metadata.input === 'object' &&
Object.keys(popup.metadata.input).length > 0 &&
popup.metadata?.tool !== 'todowrite' &&
@@ -127,45 +127,47 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
: 'Input:'}
</div>
{meta.tool === 'bash' && getInputValue('command') ? (
<div className="bg-muted/30 rounded-xl border border-border/20 mx-3">
<div className="tool-input-surface bg-transparent rounded-xl border border-border/20 mx-3">
<SyntaxHighlighter
style={syntaxTheme}
language="bash"
PreTag="div"
customStyle={toolDisplayStyles.getPopupStyles()}
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
wrapLongLines
>
{getInputValue('command')!}
</SyntaxHighlighter>
</div>
) : meta.tool === 'task' && getInputValue('prompt') ? (
<pre
className="bg-muted/30 p-3 rounded-xl border border-border/20 font-mono whitespace-pre-wrap text-foreground/90 mx-3"
<div
className="tool-input-surface bg-transparent rounded-xl border border-border/20 font-mono whitespace-pre-wrap text-foreground/90 mx-3"
style={toolDisplayStyles.getPopupStyles()}
>
{getInputValue('description') ? `Task: ${getInputValue('description')}\n` : ''}
{getInputValue('subagent_type') ? `Agent Type: ${getInputValue('subagent_type')}\n` : ''}
{`Instructions:\n${getInputValue('prompt')}`}
</pre>
</div>
) : meta.tool === 'write' && getInputValue('content') ? (
<div className="bg-muted/30 rounded-xl border border-border/20 mx-3">
<div className="tool-input-surface bg-transparent rounded-xl border border-border/20 mx-3">
<SyntaxHighlighter
style={syntaxTheme}
language={getLanguageFromExtension(getInputValue('filePath') || getInputValue('file_path') || '') || 'text'}
PreTag="div"
customStyle={toolDisplayStyles.getPopupStyles()}
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
wrapLongLines
>
{getInputValue('content')!}
</SyntaxHighlighter>
</div>
) : (
<pre
className="bg-muted/30 p-3 rounded-xl border border-border/20 font-mono whitespace-pre-wrap text-foreground/90 mx-3"
<div
className="tool-input-surface bg-transparent rounded-xl border border-border/20 font-mono whitespace-pre-wrap text-foreground/90 mx-3"
style={toolDisplayStyles.getPopupStyles()}
>
{formatInputForDisplay(input, meta.tool as string)}
</pre>
</div>
)}
</div>
);
@@ -173,7 +175,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
{popup.isDiff ? (
diffViewMode === 'unified' ? (
<div className="typography-markdown">
<div className="typography-code">
{parseDiffToUnified(popup.content).map((hunk, hunkIdx) => (
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
<div
@@ -186,7 +188,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
<div
key={lineIdx}
className={cn(
'typography-markdown font-mono px-3 py-0.5 flex',
'typography-code font-mono px-3 py-0.5 flex',
line.type === 'context' && 'bg-transparent',
line.type === 'removed' && 'bg-transparent',
line.type === 'added' && 'bg-transparent'
@@ -223,7 +225,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
@@ -232,7 +235,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
}}
codeTagProps={{
style: {
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
},
}}
>
@@ -248,7 +252,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
))}
</div>
) : popup.diffHunks ? (
<div className="typography-markdown">
<div className="typography-code">
{popup.diffHunks.map((hunk, hunkIdx) => (
<div key={hunkIdx} className="border-b border-border/20 last:border-b-0">
<div
@@ -261,7 +265,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
<div key={lineIdx} className="grid grid-cols-2 divide-x divide-border/20">
<div
className={cn(
'typography-markdown font-mono px-3 py-0.5 overflow-hidden',
'typography-code font-mono px-3 py-0.5 overflow-hidden',
line.leftLine.type === 'context' && 'bg-transparent',
line.leftLine.type === 'empty' && 'bg-transparent'
)}
@@ -292,7 +296,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
@@ -301,7 +306,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
}}
codeTagProps={{
style: {
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
},
}}
>
@@ -313,7 +319,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
</div>
<div
className={cn(
'typography-markdown font-mono px-3 py-0.5 overflow-hidden',
'typography-code font-mono px-3 py-0.5 overflow-hidden',
line.rightLine.type === 'context' && 'bg-transparent',
line.rightLine.type === 'empty' && 'bg-transparent'
)}
@@ -344,7 +350,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
@@ -353,7 +360,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
}}
codeTagProps={{
style: {
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
},
}}
>
@@ -402,7 +410,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
PreTag="div"
wrapLongLines
customStyle={toolDisplayStyles.getPopupContainerStyles()}
codeTagProps={{ style: { background: 'transparent !important' } }}
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
>
{popup.content}
</SyntaxHighlighter>
@@ -417,13 +425,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
{popup.content}
</pre>
)
);
);
}
if (tool === 'grep') {
return (
renderGrepOutput(popup.content, isMobile) || (
<pre className="typography-markdown bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
<pre className="typography-code bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
{popup.content}
</pre>
)
@@ -433,7 +441,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
if (tool === 'glob') {
return (
renderGlobOutput(popup.content, isMobile) || (
<pre className="typography-markdown bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
<pre className="typography-code bg-muted/30 p-2 rounded-xl border border-border/20 font-mono whitespace-pre-wrap">
{popup.content}
</pre>
)
@@ -444,7 +452,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
return (
<div
className={tool === 'reasoning' ? "text-muted-foreground/70" : ""}
style={{ fontSize: 'var(--text-meta)' }}
style={{ fontSize: tool === 'task' ? 'var(--text-code)' : 'var(--text-meta)' }}
>
<Streamdown mode="static" className="streamdown-content streamdown-tool">
{popup.content}
@@ -462,7 +470,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
PreTag="div"
wrapLongLines
customStyle={toolDisplayStyles.getPopupContainerStyles()}
codeTagProps={{ style: { background: 'transparent !important' } }}
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
>
{popup.content}
</SyntaxHighlighter>
@@ -491,7 +499,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit);
return (
<div key={idx} className={`typography-markdown font-mono flex ${isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
<div key={idx} className={`typography-code font-mono flex ${isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
<span className="text-muted-foreground/60 w-10 flex-shrink-0 text-right pr-4 self-start select-none">
{shouldShowLineNumber ? lineNumber : ''}
</span>
@@ -509,7 +517,8 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
@@ -518,7 +527,9 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
}}
codeTagProps={{
style: {
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
fontSize: 'inherit',
},
}}
>
@@ -540,7 +551,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
PreTag="div"
wrapLongLines
customStyle={toolDisplayStyles.getPopupContainerStyles()}
codeTagProps={{ style: { background: 'transparent !important' } }}
codeTagProps={{ style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } }}
>
{popup.content}
</SyntaxHighlighter>
@@ -550,7 +561,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
) : (
<div className="p-8 text-muted-foreground typography-ui-header">
<div className="mb-2">Command completed successfully</div>
<div className="typography-markdown">No output was produced</div>
<div className="typography-meta">No output was produced</div>
</div>
)}
</div>
@@ -1,5 +1,6 @@
import React from 'react';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { RiArrowDownSLine, RiArrowRightSLine, RiFileEditLine, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
import { Streamdown } from 'streamdown';
import { cn } from '@/lib/utils';
@@ -175,7 +176,7 @@ const ToolScrollableSection: React.FC<ToolScrollableSectionProps> = ({
}) => (
<ScrollableOverlay
outerClassName={cn('w-full min-w-0 flex-none overflow-hidden', maxHeightClass, outerClassName)}
className={cn('p-2 rounded-xl w-full min-w-0 border border-border/20 bg-muted/30', className)}
className={cn('tool-output-surface p-2 rounded-xl w-full min-w-0 border border-border/20 bg-transparent', className)}
disableHorizontal={disableHorizontal}
>
<div className="w-full min-w-0">
@@ -191,7 +192,7 @@ interface DiffPreviewProps {
}
const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, input }) => (
<div className="typography-meta px-1 pb-1 pt-0 space-y-0">
<div className="typography-code px-1 pb-1 pt-0 space-y-0">
{parseDiffToUnified(diff).map((hunk, hunkIdx) => (
<div key={hunkIdx} className="-mx-1 px-1 border-b border-border/20 last:border-b-0">
<div className="bg-muted/20 px-2 py-1 typography-meta font-medium text-muted-foreground border-b border-border/10 break-words -mx-1">
@@ -203,7 +204,7 @@ const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, input }) =
<div
key={lineIdx}
className={cn(
'typography-meta font-mono px-2 py-0.5 flex -mx-2',
'typography-code font-mono px-2 py-0.5 flex -mx-2',
line.type === 'context' && 'bg-transparent',
line.type === 'removed' && 'bg-transparent',
line.type === 'added' && 'bg-transparent'
@@ -226,23 +227,24 @@ const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, input }) =
PreTag="div"
wrapLines
wrapLongLines
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent !important',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: { background: 'transparent !important' },
}}
>
{line.content}
</SyntaxHighlighter>
customStyle={{
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' },
}}
>
{line.content}
</SyntaxHighlighter>
</div>
</div>
))}
@@ -273,7 +275,7 @@ const WriteInputPreview: React.FC<WriteInputPreviewProps> = ({ content, syntaxTh
</div>
<div className="space-y-0">
{lines.map((line, lineIdx) => (
<div key={lineIdx} className="typography-meta font-mono px-2 py-0.5 flex -mx-1">
<div key={lineIdx} className="typography-code font-mono px-2 py-0.5 flex -mx-1">
<span className="text-muted-foreground/60 w-8 flex-shrink-0 text-right pr-2 self-start select-none">
{lineIdx + 1}
</span>
@@ -288,7 +290,8 @@ const WriteInputPreview: React.FC<WriteInputPreviewProps> = ({ content, syntaxTh
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
@@ -296,7 +299,7 @@ const WriteInputPreview: React.FC<WriteInputPreviewProps> = ({ content, syntaxTh
overflowWrap: 'anywhere',
}}
codeTagProps={{
style: { background: 'transparent !important' },
style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' },
}}
>
{line || ' '}
@@ -421,7 +424,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
const listOutput = renderListOutput(outputString, { unstyled: true });
return renderScrollableBlock(
listOutput ?? (
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
<pre className="typography-code font-mono whitespace-pre-wrap break-words w-full min-w-0">
{outputString}
</pre>
)
@@ -432,7 +435,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
const grepOutput = renderGrepOutput(outputString, isMobile, { unstyled: true });
return renderScrollableBlock(
grepOutput ?? (
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
<pre className="typography-code font-mono whitespace-pre-wrap break-words w-full min-w-0">
{outputString}
</pre>
)
@@ -443,7 +446,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
const globOutput = renderGlobOutput(outputString, isMobile, { unstyled: true });
return renderScrollableBlock(
globOutput ?? (
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
<pre className="typography-code font-mono whitespace-pre-wrap break-words w-full min-w-0">
{outputString}
</pre>
)
@@ -464,7 +467,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
const webSearchContent = renderWebSearchOutput(outputString, syntaxTheme, { unstyled: true });
return renderScrollableBlock(
webSearchContent ?? (
<pre className="typography-meta font-mono whitespace-pre-wrap break-words w-full min-w-0">
<pre className="typography-code font-mono whitespace-pre-wrap break-words w-full min-w-0">
{outputString}
</pre>
)
@@ -497,14 +500,14 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
const isInfoMessage = (line: string) => line.trim().startsWith('(');
return renderScrollableBlock(
<div className="typography-meta w-full min-w-0 space-y-1">
<div className="typography-code w-full min-w-0 space-y-1">
{lines.map((line: string, idx: number) => {
const isInfo = isInfoMessage(line);
const lineNumber = offset + idx + 1;
const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit);
return (
<div key={idx} className={cn('typography-meta font-mono flex w-full min-w-0', isInfo && 'text-muted-foreground/70 italic')}>
<div key={idx} className={cn('typography-code font-mono flex w-full min-w-0', isInfo && 'text-muted-foreground/70 italic')}>
<span className="text-muted-foreground/60 w-8 flex-shrink-0 text-right pr-3 self-start select-none">
{shouldShowLineNumber ? lineNumber : ''}
</span>
@@ -522,7 +525,8 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
margin: 0,
padding: 0,
fontSize: 'inherit',
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
borderRadius: 0,
overflow: 'visible',
whiteSpace: 'pre-wrap',
@@ -531,7 +535,8 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
}}
codeTagProps={{
style: {
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
},
}}
>
@@ -559,7 +564,8 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
}}
codeTagProps={{
style: {
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
},
}}
wrapLongLines
@@ -603,10 +609,10 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
) : hasInputText ? (
<div className="my-1">
{renderScrollableBlock(
<blockquote className="whitespace-pre-wrap break-words typography-meta italic text-muted-foreground/70">
<blockquote className="tool-input-text whitespace-pre-wrap break-words typography-meta italic text-muted-foreground/70">
{inputTextContent}
</blockquote>,
{ maxHeightClass: 'max-h-60' }
{ maxHeightClass: 'max-h-60', className: 'tool-input-surface' }
)}
</div>
) : null}
@@ -674,10 +680,38 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
const stateWithData = state as ToolStateWithMetadata;
const metadata = stateWithData.metadata;
const input = stateWithData.input;
const diffStats = (part.tool === 'edit' || part.tool === 'multiedit') ? parseDiffStats(metadata) : null;
const description = getToolDescription(part, state, isMobile, currentDirectory);
const displayName = getToolMetadata(part.tool).displayName;
const runtime = React.useContext(RuntimeAPIContext);
const handleMainClick = (e: React.MouseEvent) => {
if (!runtime?.editor) {
onToggle(part.id);
return;
}
let filePath: unknown;
if (part.tool === 'edit' || part.tool === 'multiedit') {
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
} else if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool)) {
filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path;
}
if (typeof filePath === 'string') {
e.stopPropagation();
let absolutePath = filePath;
if (!filePath.startsWith('/')) {
absolutePath = currentDirectory.endsWith('/') ? currentDirectory + filePath : currentDirectory + '/' + filePath;
}
runtime.editor.openFile(absolutePath);
} else {
onToggle(part.id);
}
};
if (!isFinalized) {
return null;
}
@@ -689,11 +723,11 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
className={cn(
'group/tool flex items-center gap-2 pr-2 pl-px py-1.5 rounded-xl cursor-pointer'
)}
onClick={() => onToggle(part.id)}
onClick={handleMainClick}
>
<div className="flex items-center gap-2 flex-shrink-0">
{}
<div className="relative h-3.5 w-3.5 flex-shrink-0">
<div className="relative h-3.5 w-3.5 flex-shrink-0" onClick={(e) => { e.stopPropagation(); onToggle(part.id); }}>
{}
<div
className={cn(
@@ -34,6 +34,8 @@ export function WorkingPlaceholder({
const fadeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resultTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const transitionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rafIdRef = useRef<number | null>(null);
const lastCheckTimeRef = useRef<number>(0);
const lastActiveStatusRef = useRef<string | null>(null);
const hasShownActivityRef = useRef<boolean>(false);
const wasAbortedRef = useRef<boolean>(false);
@@ -207,7 +209,16 @@ export function WorkingPlaceholder({
wasAbortedRef.current = false;
};
const checkInterval = setInterval(() => {
const CHECK_THROTTLE_MS = 150; // Throttle checks to ~6-7 times per second
const checkLoop = (timestamp: number) => {
// Throttle: skip if less than CHECK_THROTTLE_MS since last check
if (timestamp - lastCheckTimeRef.current < CHECK_THROTTLE_MS) {
rafIdRef.current = requestAnimationFrame(checkLoop);
return;
}
lastCheckTimeRef.current = timestamp;
const now = Date.now();
const elapsed = now - displayStartTimeRef.current;
@@ -216,6 +227,7 @@ export function WorkingPlaceholder({
const shouldWaitForMinTime = !isDone && statusQueueRef.current.length > 0;
if (shouldWaitForMinTime && elapsed < MIN_DISPLAY_TIME) {
rafIdRef.current = requestAnimationFrame(checkLoop);
return;
}
@@ -257,14 +269,24 @@ export function WorkingPlaceholder({
lastActiveStatusRef.current = null;
removalPendingRef.current = false;
wasAbortedRef.current = false;
rafIdRef.current = requestAnimationFrame(checkLoop);
return;
}
startFadeOut(result);
}
}, 50);
return () => clearInterval(checkInterval);
rafIdRef.current = requestAnimationFrame(checkLoop);
};
rafIdRef.current = requestAnimationFrame(checkLoop);
return () => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
};
}, [isFadingOut]);
@@ -70,7 +70,7 @@ export const renderListOutput = (output: string, options?: { unstyled?: boolean
'w-full min-w-0 font-mono space-y-0.5',
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
)}
style={typography.micro}
style={typography.tool.popup}
>
{items.map((item, idx) => (
<div key={idx} className="min-w-0" style={{ paddingLeft: `${item.depth * 20}px` }}>
@@ -115,13 +115,14 @@ export const renderGrepOutput = (output: string, isMobile: boolean, options?: {
'space-y-2 w-full min-w-0',
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
)}
style={typography.tool.popup}
>
<div className="typography-meta text-muted-foreground mb-2">
Found {lines.length} match{lines.length !== 1 ? 'es' : ''}
</div>
{Object.entries(fileGroups).map(([filepath, matches]) => (
<div key={filepath} className="space-y-1">
<div className={cn('font-medium text-muted-foreground', isMobile ? 'typography-micro' : 'typography-meta')}>
<div className={cn('font-medium text-muted-foreground', isMobile ? 'typography-micro' : 'typography-code')}>
{filepath}
</div>
<div className="pl-4 space-y-1">
@@ -130,7 +131,7 @@ export const renderGrepOutput = (output: string, isMobile: boolean, options?: {
return null;
}
return (
<div key={idx} className={cn('flex items-start gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-meta')}>
<div key={idx} className={cn('flex items-start gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-code')}>
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0 mt-1.5" style={{ backgroundColor: 'var(--status-info)', opacity: 0.6 }} />
<div className="flex gap-2 min-w-0 flex-1">
{match.lineNum && (
@@ -181,18 +182,19 @@ export const renderGlobOutput = (output: string, isMobile: boolean, options?: {
'space-y-2 w-full min-w-0',
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
)}
style={typography.tool.popup}
>
<div className="typography-meta text-muted-foreground mb-2">
Found {paths.length} file{paths.length !== 1 ? 's' : ''}
</div>
{sortedDirs.map((dir) => (
<div key={dir} className="space-y-1">
<div className={cn('font-medium text-muted-foreground', isMobile ? 'typography-micro' : 'typography-meta')}>
<div className={cn('font-medium text-muted-foreground', isMobile ? 'typography-micro' : 'typography-code')}>
{dir}/
</div>
<div className={cn('pl-4 grid gap-1', isMobile ? 'grid-cols-1' : 'grid-cols-2')}>
{groups[dir].sort().map((filename) => (
<div key={filename} className={cn('flex items-center gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-meta')}>
<div key={filename} className={cn('flex items-center gap-2 min-w-0', isMobile ? 'typography-micro' : 'typography-code')}>
<div className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ backgroundColor: 'var(--status-info)', opacity: 0.6 }} />
<span className="text-foreground font-mono truncate">{filename}</span>
</div>
@@ -248,6 +250,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
'space-y-3 w-full min-w-0',
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/30'
)}
style={typography.tool.popup}
>
<div className="flex gap-4 typography-meta pb-2 border-b border-border/20">
<span className="font-medium" style={{ color: 'var(--muted-foreground)' }}>Total: {todos.length}</span>
@@ -275,7 +278,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
{todosByStatus.in_progress.map((todo, idx) => (
<div key={todo.id || idx} className="flex items-start gap-2">
{getPriorityDot(todo.priority)}
<span className="typography-meta text-foreground flex-1 leading-relaxed">{todo.content}</span>
<span className="typography-code text-foreground flex-1 leading-relaxed">{todo.content}</span>
</div>
))}
</div>
@@ -292,7 +295,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
{todosByStatus.pending.map((todo, idx) => (
<div key={todo.id || idx} className="flex items-start gap-2">
{getPriorityDot(todo.priority)}
<span className="typography-meta text-foreground flex-1 leading-relaxed">{todo.content}</span>
<span className="typography-code text-foreground flex-1 leading-relaxed">{todo.content}</span>
</div>
))}
</div>
@@ -309,7 +312,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
{todosByStatus.completed.map((todo, idx) => (
<div key={todo.id || idx} className="flex items-start gap-2">
<RiCheckLine className="w-3 h-3 mt-0.5 flex-shrink-0" style={{ color: 'var(--status-success)', opacity: 0.7 }} />
<span className="typography-meta text-foreground flex-1 leading-relaxed">{todo.content}</span>
<span className="typography-code text-foreground flex-1 leading-relaxed">{todo.content}</span>
</div>
))}
</div>
@@ -326,7 +329,7 @@ export const renderTodoOutput = (output: string, options?: { unstyled?: boolean
{todosByStatus.cancelled.map((todo, idx) => (
<div key={todo.id || idx} className="flex items-start gap-2">
<span className="w-3 h-3 text-muted-foreground/50 mt-0.5 flex-shrink-0">×</span>
<span className="typography-meta text-muted-foreground/50 line-through flex-1 leading-relaxed">{todo.content}</span>
<span className="typography-code text-muted-foreground/50 line-through flex-1 leading-relaxed">{todo.content}</span>
</div>
))}
</div>
@@ -344,9 +347,10 @@ export const renderWebSearchOutput = (output: string, _syntaxTheme: { [key: stri
return (
<div
className={cn(
'typography-meta max-w-none w-full min-w-0',
'typography-code max-w-none w-full min-w-0',
options?.unstyled ? null : 'p-3 bg-muted/20 rounded-xl border border-border/20'
)}
style={typography.tool.popup}
>
<Streamdown mode="static" className="streamdown-content streamdown-tool">
{output}
@@ -0,0 +1,299 @@
import React from 'react';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { SessionSidebar } from '@/components/session/SessionSidebar';
import { ChatView } from '@/components/views';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { RiAddLine, RiArrowLeftLine } from '@remixicon/react';
import { RiLoader4Line } from '@remixicon/react';
type VSCodeView = 'sessions' | 'chat';
export const VSCodeLayout: React.FC = () => {
const [currentView, setCurrentView] = React.useState<VSCodeView>('sessions');
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessionStore((state) => state.sessions);
const createSession = useSessionStore((state) => state.createSession);
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
() => (typeof window !== 'undefined'
? (window as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status as
'connecting' | 'connected' | 'error' | 'disconnected' | undefined
: 'connecting') || 'connecting'
);
const [connectionError, setConnectionError] = React.useState<string | undefined>(
() => (typeof window !== 'undefined'
? (window as { __OPENCHAMBER_CONNECTION__?: { error?: string } }).__OPENCHAMBER_CONNECTION__?.error
: undefined),
);
const [hasEverConnected, setHasEverConnected] = React.useState<boolean>(() => connectionStatus === 'connected');
const [overlayVisible, setOverlayVisible] = React.useState<boolean>(() => connectionStatus !== 'connected');
const overlayTimer = React.useRef<number | null>(null);
const configInitialized = useConfigStore((state) => state.isInitialized);
const initializeConfig = useConfigStore((state) => state.initializeApp);
const loadSessions = useSessionStore((state) => state.loadSessions);
const loadMessages = useSessionStore((state) => state.loadMessages);
const messages = useSessionStore((state) => state.messages);
const [hasInitializedOnce, setHasInitializedOnce] = React.useState<boolean>(() => configInitialized);
const [isInitializing, setIsInitializing] = React.useState<boolean>(false);
const autoSelectedRef = React.useRef<boolean>(false);
const startedFreshSessionRef = React.useRef<boolean>(false);
// Navigate to chat when a session is selected
React.useEffect(() => {
if (currentSessionId) {
setCurrentView('chat');
}
}, [currentSessionId]);
// If the active session disappears (e.g., deleted), stay on the sessions list
React.useEffect(() => {
if (!currentSessionId && currentView === 'chat') {
setCurrentView('sessions');
}
}, [currentSessionId, currentView]);
const handleBackToSessions = React.useCallback(() => {
setCurrentView('sessions');
}, []);
const handleNewSession = React.useCallback(async () => {
const result = await createSession();
if (result?.id) {
setCurrentView('chat');
}
}, [createSession]);
React.useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail;
const status = detail?.status;
if (status === 'connected' || status === 'connecting' || status === 'error' || status === 'disconnected') {
setConnectionStatus(status);
setConnectionError(detail?.error);
if (status === 'connected') {
setHasEverConnected(true);
}
}
};
window.addEventListener('openchamber:connection-status', handler as EventListener);
return () => window.removeEventListener('openchamber:connection-status', handler as EventListener);
}, []);
const showConnectionOverlay = React.useMemo(() => {
if (hasInitializedOnce && connectionStatus === 'connected' && !isInitializing) {
return false;
}
if (!hasInitializedOnce) {
return connectionStatus !== 'connected' || isInitializing;
}
return connectionStatus === 'error';
}, [connectionStatus, hasInitializedOnce, isInitializing]);
const overlayDelay = hasEverConnected ? 800 : 250;
React.useEffect(() => {
if (overlayTimer.current) {
window.clearTimeout(overlayTimer.current);
overlayTimer.current = null;
}
if (showConnectionOverlay) {
overlayTimer.current = window.setTimeout(() => setOverlayVisible(true), overlayDelay);
} else {
setOverlayVisible(false);
}
return () => {
if (overlayTimer.current) {
window.clearTimeout(overlayTimer.current);
overlayTimer.current = null;
}
};
}, [overlayDelay, showConnectionOverlay]);
React.useEffect(() => {
const runBootstrap = async () => {
if (isInitializing || hasInitializedOnce || connectionStatus !== 'connected') {
return;
}
setIsInitializing(true);
try {
if (!configInitialized) {
await initializeConfig();
}
await loadSessions();
setHasInitializedOnce(true);
} catch {
// Ignore bootstrap failures; overlay will remain until next attempt
} finally {
setIsInitializing(false);
}
};
void runBootstrap();
}, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing, loadSessions]);
React.useEffect(() => {
const hydrateMessages = async () => {
if (!hasInitializedOnce || connectionStatus !== 'connected' || currentView !== 'chat') {
return;
}
const targetSessionId = currentSessionId || sessions[0]?.id;
if (!targetSessionId) return;
const hasMessages = messages.has(targetSessionId) && (messages.get(targetSessionId)?.length || 0) > 0;
if (!hasMessages) {
if (!currentSessionId) {
setCurrentSession(targetSessionId);
}
try {
await loadMessages(targetSessionId);
} catch { /* ignored */ }
}
};
void hydrateMessages();
}, [connectionStatus, currentSessionId, currentView, hasInitializedOnce, loadMessages, messages, sessions, setCurrentSession]);
React.useEffect(() => {
if (!hasInitializedOnce || autoSelectedRef.current) {
return;
}
if (!currentSessionId && sessions.length > 0) {
setCurrentSession(sessions[0].id);
autoSelectedRef.current = true;
}
}, [currentSessionId, hasInitializedOnce, sessions, setCurrentSession]);
React.useEffect(() => {
const ensureFreshSession = async () => {
if (connectionStatus !== 'connected' || !hasInitializedOnce || startedFreshSessionRef.current) {
return;
}
const current = sessions.find((s) => s.id === currentSessionId);
const isCurrentPlaceholder = current?.title?.toLowerCase()?.startsWith('new session');
if (current && isCurrentPlaceholder) {
setCurrentSession(current.id);
} else {
// Look for an existing empty session to reuse before creating a new one
const reusableSession = sessions.find((s) => s.title?.toLowerCase()?.startsWith('new session'));
if (reusableSession) {
setCurrentSession(reusableSession.id);
} else {
const newSession = await createSession();
if (newSession?.id) {
setCurrentSession(newSession.id);
}
}
}
startedFreshSessionRef.current = true;
};
void ensureFreshSession();
}, [connectionStatus, createSession, currentSessionId, hasInitializedOnce, sessions, setCurrentSession]);
return (
<div className="h-full w-full bg-background text-foreground flex flex-col">
{currentView === 'sessions' ? (
<div className="flex flex-col h-full">
<VSCodeHeader title="Sessions" onNewSession={handleNewSession} />
<div className="flex-1 overflow-hidden">
<SessionSidebar
mobileVariant
allowReselect
onSessionSelected={() => setCurrentView('chat')}
hideDirectoryControls
/>
</div>
</div>
) : (
<div className="flex flex-col h-full">
<VSCodeHeader
title={sessions.find(s => s.id === currentSessionId)?.title || 'Chat'}
showBack
onBack={handleBackToSessions}
showContextUsage
/>
<div className="flex-1 overflow-hidden">
<ErrorBoundary>
<ChatView />
</ErrorBoundary>
</div>
</div>
)}
{overlayVisible && (
<div className="absolute inset-0 z-50 bg-background/90 backdrop-blur-sm flex flex-col items-center justify-center gap-3 text-center px-4">
<RiLoader4Line className="h-7 w-7 animate-spin text-muted-foreground" />
<div className="text-sm font-medium">
{connectionStatus === 'connecting'
? (hasEverConnected ? 'Reconnecting to OpenCode…' : 'Starting OpenCode API…')
: 'Lost connection to OpenCode'}
</div>
{connectionError && (
<div className="text-xs text-muted-foreground max-w-md">
{connectionError}
</div>
)}
</div>
)}
</div>
);
};
interface VSCodeHeaderProps {
title: string;
showBack?: boolean;
onBack?: () => void;
onNewSession?: () => void;
showContextUsage?: boolean;
}
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, showContextUsage }) => {
const { getCurrentModel } = useConfigStore();
const getContextUsage = useSessionStore((state) => state.getContextUsage);
const currentModel = getCurrentModel();
const limits = (currentModel?.limit && typeof currentModel.limit === 'object'
? currentModel.limit
: null) as { context?: number; output?: number } | null;
const contextLimit = typeof limits?.context === 'number' ? limits.context : 0;
const outputLimit = typeof limits?.output === 'number' ? limits.output : 0;
const contextUsage = getContextUsage(contextLimit, outputLimit);
return (
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-background shrink-0">
{showBack && onBack && (
<button
onClick={onBack}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="Back to sessions"
>
<RiArrowLeftLine className="h-5 w-5" />
</button>
)}
<h1 className="text-sm font-medium truncate flex-1" title={title}>{title}</h1>
{onNewSession && (
<button
onClick={onNewSession}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="New session"
>
<RiAddLine className="h-5 w-5" />
</button>
)}
{showContextUsage && contextUsage && contextUsage.totalTokens > 0 && (
<ContextUsageDisplay
totalTokens={contextUsage.totalTokens}
percentage={contextUsage.percentage}
contextLimit={contextUsage.contextLimit}
outputLimit={contextUsage.outputLimit ?? 0}
size="compact"
/>
)}
</div>
);
};
@@ -107,9 +107,17 @@ type SessionGroup = {
interface SessionSidebarProps {
mobileVariant?: boolean;
onSessionSelected?: (sessionId: string) => void;
allowReselect?: boolean;
hideDirectoryControls?: boolean;
}
export const SessionSidebar: React.FC<SessionSidebarProps> = ({ mobileVariant = false }) => {
export const SessionSidebar: React.FC<SessionSidebarProps> = ({
mobileVariant = false,
onSessionSelected,
allowReselect = false,
hideDirectoryControls = false,
}) => {
const [editingId, setEditingId] = React.useState<string | null>(null);
const [editTitle, setEditTitle] = React.useState('');
const [copiedSessionId, setCopiedSessionId] = React.useState<string | null>(null);
@@ -336,9 +344,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({ mobileVariant =
if (disabled) {
return;
}
if (!allowReselect && sessionId === currentSessionId) {
onSessionSelected?.(sessionId);
return;
}
setCurrentSession(sessionId);
onSessionSelected?.(sessionId);
},
[setCurrentSession],
[allowReselect, currentSessionId, onSessionSelected, setCurrentSession],
);
const handleSaveEdit = React.useCallback(async () => {
@@ -922,48 +935,50 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({ mobileVariant =
mobileVariant ? '' : isDesktopRuntime ? 'bg-transparent' : 'bg-sidebar',
)}
>
<div className="h-14 select-none px-2 flex-shrink-0">
<div className="flex h-full items-center gap-0">
<button
type="button"
onClick={handleOpenDirectoryDialog}
className={cn(
'group flex min-w-0 flex-1 items-center gap-2 rounded-md px-0 py-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
!isDesktopRuntime && 'hover:bg-sidebar/20',
)}
aria-label="Change project directory"
title={directoryTooltip || '/'}
>
<span
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground group-hover:text-foreground',
!isDesktopRuntime && 'bg-sidebar/60',
)}
>
<RiFolder6Line className="h-[1.125rem] w-[1.125rem] translate-y-px" />
</span>
<div className="min-w-0 flex-1 overflow-hidden">
<p className="truncate whitespace-nowrap typography-ui font-semibold text-muted-foreground group-hover:text-foreground">
{displayDirectory || '/'}
</p>
</div>
</button>
{isGitRepo ? (
{!hideDirectoryControls && (
<div className="h-14 select-none px-2 flex-shrink-0">
<div className="flex h-full items-center gap-0">
<button
type="button"
onClick={handleOpenWorktreeManager}
onClick={handleOpenDirectoryDialog}
className={cn(
'inline-flex h-10 w-7 flex-shrink-0 items-center justify-center rounded-xl text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
!isDesktopRuntime && 'bg-sidebar/60 hover:bg-sidebar',
'group flex min-w-0 flex-1 items-center gap-2 rounded-md px-0 py-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
!isDesktopRuntime && 'hover:bg-sidebar/20',
)}
aria-label="Manage worktrees"
aria-label="Change project directory"
title={directoryTooltip || '/'}
>
<RiGitRepositoryLine className="h-[1.125rem] w-[1.125rem] translate-y-px" />
<span
className={cn(
'flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground group-hover:text-foreground',
!isDesktopRuntime && 'bg-sidebar/60',
)}
>
<RiFolder6Line className="h-[1.125rem] w-[1.125rem] translate-y-px" />
</span>
<div className="min-w-0 flex-1 overflow-hidden">
<p className="truncate whitespace-nowrap typography-ui font-semibold text-muted-foreground group-hover:text-foreground">
{displayDirectory || '/'}
</p>
</div>
</button>
) : null}
{isGitRepo ? (
<button
type="button"
onClick={handleOpenWorktreeManager}
className={cn(
'inline-flex h-10 w-7 flex-shrink-0 items-center justify-center rounded-xl text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
!isDesktopRuntime && 'bg-sidebar/60 hover:bg-sidebar',
)}
aria-label="Manage worktrees"
>
<RiGitRepositoryLine className="h-[1.125rem] w-[1.125rem] translate-y-px" />
</button>
) : null}
</div>
</div>
</div>
)}
<ScrollableOverlay
outerClassName="flex-1 min-h-0"
@@ -971,6 +986,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({ mobileVariant =
>
{groupedSessions.length === 0 ? (
emptyState
) : hideDirectoryControls && groupedSessions.length === 1 && groupedSessions[0].isMain ? (
<div className="space-y-[0.6rem] py-1">
{groupedSessions[0].sessions.length === 0 ? (
<div className="py-1 text-left typography-micro text-muted-foreground">
No sessions yet.
</div>
) : (
groupedSessions[0].sessions.map((node) => renderSessionNode(node, 0, groupedSessions[0].directory))
)}
</div>
) : (
groupedSessions.map((group) => (
<div key={group.id} className="relative">
@@ -997,30 +1022,32 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({ mobileVariant =
<span className="typography-micro font-medium text-muted-foreground truncate group-hover/header:text-foreground">
{group.label}
</span>
<span
role="button"
tabIndex={0}
aria-disabled={isCreatingSession}
className={cn(
'inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
isCreatingSession && 'opacity-40 cursor-default',
)}
aria-label="Create session in this group"
onClick={(e) => {
if (isCreatingSession) return;
e.stopPropagation();
handleCreateSessionInGroup(group.directory);
}}
onKeyDown={(e) => {
if (isCreatingSession) return;
if (e.key === 'Enter' || e.key === ' ') {
{!hideDirectoryControls && (
<span
role="button"
tabIndex={0}
aria-disabled={isCreatingSession}
className={cn(
'inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
isCreatingSession && 'opacity-40 cursor-default',
)}
aria-label="Create session in this group"
onClick={(e) => {
if (isCreatingSession) return;
e.stopPropagation();
handleCreateSessionInGroup(group.directory);
}
}}
>
<RiAddLine className="h-4.5 w-4.5" />
</span>
}}
onKeyDown={(e) => {
if (isCreatingSession) return;
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
handleCreateSessionInGroup(group.directory);
}
}}
>
<RiAddLine className="h-4.5 w-4.5" />
</span>
)}
</div>
</button>
@@ -6,7 +6,7 @@ import React, {
} from 'react';
import type { Theme, ThemeMode } from '@/types/theme';
import type { DesktopSettings } from '@/lib/desktop';
import { isDesktopRuntime } from '@/lib/desktop';
import { isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
import { CSSVariableGenerator } from '@/lib/theme/cssGenerator';
import { updateDesktopSettings } from '@/lib/persistence';
import {
@@ -16,6 +16,8 @@ import {
flexokiDarkTheme,
} from '@/lib/theme/themes';
import { ThemeSystemContext, type ThemeContextValue } from './theme-system-context';
import type { VSCodeThemePayload } from '@/lib/theme/vscode/adapter';
import { useUIStore } from '@/stores/useUIStore';
type ThemePreferences = {
themeMode: ThemeMode;
@@ -50,6 +52,8 @@ const ensureThemeById = (themeId: string, variant: 'light' | 'dark'): Theme => {
return fallback ?? fallbackThemeForVariant(variant);
};
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
const validateThemeId = (themeId: string | null, variant: 'light' | 'dark'): string => {
if (!themeId) {
return variant === 'light' ? DEFAULT_LIGHT_ID : DEFAULT_DARK_ID;
@@ -126,8 +130,19 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
const cssGenerator = useMemo(() => new CSSVariableGenerator(), []);
const [preferences, setPreferences] = useState<ThemePreferences>(() => buildInitialPreferences(defaultThemeId));
const [systemPrefersDark, setSystemPrefersDark] = useState<boolean>(() => getSystemPreference());
const [vscodeTheme, setVSCodeTheme] = useState<Theme | null>(() => {
if (typeof window === 'undefined' || !isVSCodeRuntime()) {
return null;
}
const existing = (window as unknown as { __OPENCHAMBER_VSCODE_THEME__?: Theme }).__OPENCHAMBER_VSCODE_THEME__;
return existing || null;
});
const isVSCode = useMemo(() => isVSCodeRuntime(), []);
const currentTheme = useMemo(() => {
if (isVSCode && vscodeTheme) {
return vscodeTheme;
}
if (preferences.themeMode === 'light') {
return ensureThemeById(preferences.lightThemeId, 'light');
}
@@ -137,9 +152,42 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return systemPrefersDark
? ensureThemeById(preferences.darkThemeId, 'dark')
: ensureThemeById(preferences.lightThemeId, 'light');
}, [preferences, systemPrefersDark]);
}, [isVSCode, preferences, systemPrefersDark, vscodeTheme]);
const availableThemes = themes;
const availableThemes = useMemo(
() => (isVSCode && vscodeTheme ? [vscodeTheme, ...themes] : themes),
[isVSCode, vscodeTheme],
);
useEffect(() => {
if (!isVSCode) {
return;
}
const applyVSCodeTheme = (theme: Theme) => {
setVSCodeTheme(theme);
const variant: ThemeMode = theme.metadata.variant === 'dark' ? 'dark' : 'light';
const uiStore = useUIStore.getState();
if (uiStore.theme !== variant) {
uiStore.setTheme(variant);
}
};
const handleThemeEvent = (event: Event) => {
const detail = (event as CustomEvent<VSCodeThemePayload>).detail;
if (detail?.theme) {
applyVSCodeTheme(detail.theme);
}
};
const existing = (window as unknown as { __OPENCHAMBER_VSCODE_THEME__?: Theme }).__OPENCHAMBER_VSCODE_THEME__;
if (existing) {
applyVSCodeTheme(existing);
}
window.addEventListener('openchamber:vscode-theme', handleThemeEvent as EventListener);
return () => window.removeEventListener('openchamber:vscode-theme', handleThemeEvent as EventListener);
}, [isVSCode]);
const updateBrowserChrome = useCallback((theme: Theme) => {
if (typeof document === 'undefined') {
@@ -177,17 +225,25 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
metaThemeColorMedia.setAttribute('content', chromeColor);
}, []);
useEffect(() => {
const applyVSCodeRuntimeClass = useCallback((enabled: boolean) => {
if (typeof document === 'undefined') {
return;
}
document.documentElement.classList.toggle('vscode-runtime', enabled);
}, []);
useIsomorphicLayoutEffect(() => {
if (typeof window === 'undefined') {
return;
}
cssGenerator.apply(currentTheme);
applyVSCodeRuntimeClass(isVSCode);
updateBrowserChrome(currentTheme);
const root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(currentTheme.metadata.variant);
}, [cssGenerator, currentTheme, updateBrowserChrome]);
}, [applyVSCodeRuntimeClass, cssGenerator, currentTheme, isVSCode, updateBrowserChrome]);
useEffect(() => {
if (preferences.themeMode !== 'system' || typeof window === 'undefined') {
+29 -6
View File
@@ -56,7 +56,7 @@ interface UseChatScrollManagerResult {
handleMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
showScrollButton: boolean;
scrollToBottom: (options?: { instant?: boolean }) => void;
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
spacerHeight: number;
pendingAnchorId: string | null;
hasActiveAnchor: boolean;
@@ -116,6 +116,7 @@ export const useChatScrollManager = ({
const anchorIdRef = React.useRef<string | null>(null);
const hasAnchoredOnceRef = React.useRef<boolean>(false);
const userScrollOverrideRef = React.useRef<boolean>(false);
const currentPhase = currentSessionId
? sessionActivityPhase?.get(currentSessionId) ?? 'idle'
@@ -238,13 +239,30 @@ export const useChatScrollManager = ({
}
}, [pendingAnchorId]);
const scrollToBottom = React.useCallback((options?: { instant?: boolean }) => {
const scrollToBottom = React.useCallback((options?: { instant?: boolean; force?: boolean }) => {
const container = scrollRef.current;
if (!container) return;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
const shouldRespectUserScroll =
userScrollOverrideRef.current &&
currentPhase === 'idle' &&
!isSyncing &&
!options?.force &&
distanceFromBottom > DEFAULT_SCROLL_BUTTON_THRESHOLD;
if (shouldRespectUserScroll) {
return;
}
if (options?.force) {
userScrollOverrideRef.current = false;
}
const bottom = container.scrollHeight - container.clientHeight;
scrollEngine.scrollToPosition(Math.max(0, bottom), options);
}, [scrollEngine]);
}, [currentPhase, isSyncing, scrollEngine]);
const scrollToNewAnchor = React.useCallback((messageId: string) => {
if (lastScrolledAnchorIdRef.current === messageId) {
@@ -294,12 +312,16 @@ export const useChatScrollManager = ({
});
}, [scrollEngine, updateSpacerHeight]);
const handleScrollEvent = React.useCallback(() => {
const handleScrollEvent = React.useCallback((event?: Event) => {
const container = scrollRef.current;
if (!container || !currentSessionId) {
return;
}
if (event?.isTrusted) {
userScrollOverrideRef.current = true;
}
scrollEngine.handleScroll();
updateScrollButtonVisibility();
@@ -328,10 +350,10 @@ export const useChatScrollManager = ({
const container = scrollRef.current;
if (!container) return;
container.addEventListener('scroll', handleScrollEvent, { passive: true });
container.addEventListener('scroll', handleScrollEvent as EventListener, { passive: true });
return () => {
container.removeEventListener('scroll', handleScrollEvent);
container.removeEventListener('scroll', handleScrollEvent as EventListener);
};
}, [handleScrollEvent]);
@@ -376,6 +398,7 @@ export const useChatScrollManager = ({
spacerHeightRef.current = 0;
setSpacerHeight(0);
userScrollOverrideRef.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run on session change, not message changes
}, [currentSessionId, sessionMessages.length]);
+2
View File
@@ -16,3 +16,5 @@ export const useRuntimeAPI = <TValue,>(selector: RuntimeAPISelector<TValue>): TV
};
export const useIsDesktopRuntime = (): boolean => useRuntimeAPI((api) => api.runtime.isDesktop);
export const useIsVSCodeRuntime = (): boolean => useRuntimeAPI((api) => api.runtime.isVSCode);
+9 -19
View File
@@ -75,25 +75,15 @@ export const useUpdateCheck = (checkOnMount = true): UseUpdateCheckReturn => {
error: null,
});
const [mockMode, setMockMode] = useState(shouldMockUpdate);
const [mockState, setMockState] = useState<UpdateState | null>(null);
// Check for mock mode changes (for console toggling)
useEffect(() => {
const interval = setInterval(() => {
const shouldMock = shouldMockUpdate();
if (shouldMock !== mockMode) {
setMockMode(shouldMock);
if (shouldMock) {
const config = getMockConfig();
if (config) {
setMockState(createMockUpdate(config));
}
}
}
}, 500);
return () => clearInterval(interval);
}, [mockMode]);
// Only check mock mode once at startup - no polling
const [mockMode] = useState(shouldMockUpdate);
const [mockState, setMockState] = useState<UpdateState | null>(() => {
if (shouldMockUpdate()) {
const config = getMockConfig();
return config ? createMockUpdate(config) : null;
}
return null;
});
const checkForUpdates = useCallback(async () => {
if (mockMode) {
+46
View File
@@ -1259,9 +1259,55 @@ html:not(.dark) .chat-scroll {
Minimal overrides - fonts only, using Streamdown defaults
============================================ */
/* VS Code webviews can inject default pre/code/blockquote backgrounds; tool input/output should inherit the card surface. */
.tool-input-surface pre,
.tool-input-surface code,
.tool-input-surface blockquote,
.tool-input-text {
background: transparent !important;
background-color: transparent !important;
}
/* VSCode: tool cards should honor semantic code sizing, even when syntax themes inject sizes. */
:root.vscode-runtime .tool-input-surface,
:root.vscode-runtime .tool-output-surface {
font-size: var(--text-code) !important;
}
:root.vscode-runtime .tool-input-surface code,
:root.vscode-runtime .tool-input-surface pre,
:root.vscode-runtime .tool-output-surface code,
:root.vscode-runtime .tool-output-surface pre {
font-size: inherit !important;
}
/* Model/agent controls: collapse labels in narrow containers (mobile + VSCode side panel). */
@container model-controls (max-width: 15rem) {
.model-controls__agent-label {
display: none;
}
}
@container model-controls (max-width: 12rem) {
.model-controls__model-label {
display: none;
}
}
/* Text font: IBM Plex Sans */
.streamdown-content {
font-family: var(--font-sans);
font-size: var(--text-markdown);
}
/* Tool markdown should render at code size to match tool card typography. */
.streamdown-content.streamdown-tool {
font-size: var(--text-code) !important;
}
.streamdown-content.streamdown-tool code,
.streamdown-content.streamdown-tool pre {
font-size: inherit !important;
}
/* Code font: IBM Plex Mono */
+9 -1
View File
@@ -1,12 +1,14 @@
import type { WorktreeMetadata } from '@/types/worktree';
export type RuntimePlatform = 'web' | 'desktop';
export type RuntimePlatform = 'web' | 'desktop' | 'vscode';
export interface RuntimeDescriptor {
platform: RuntimePlatform;
isDesktop: boolean;
isVSCode: boolean;
label?: string;
}
@@ -386,6 +388,11 @@ export interface ToolsAPI {
getAvailableTools(): Promise<string[]>;
}
export interface EditorAPI {
openFile(path: string, line?: number, column?: number): Promise<void>;
openDiff(original: string, modified: string, label?: string): Promise<void>;
}
export interface RuntimeAPIs {
runtime: RuntimeDescriptor;
terminal: TerminalAPI;
@@ -396,6 +403,7 @@ export interface RuntimeAPIs {
notifications: NotificationsAPI;
diagnostics?: DiagnosticsAPI;
tools: ToolsAPI;
editor?: EditorAPI;
worktrees?: WorktreeMetadata[];
}
+6
View File
@@ -65,6 +65,12 @@ export type DesktopApi = {
export const isDesktopRuntime = (): boolean =>
typeof window !== "undefined" && typeof window.opencodeDesktop !== "undefined";
export const isVSCodeRuntime = (): boolean => {
if (typeof window === "undefined") return false;
const apis = (window as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__;
return apis?.runtime?.isVSCode === true;
};
export const getDesktopApi = (): DesktopApi | null => {
if (!isDesktopRuntime()) {
return null;
+9 -7
View File
@@ -1,5 +1,6 @@
import type { Theme } from '@/types/theme';
import { SEMANTIC_TYPOGRAPHY } from '@/lib/typography';
import { SEMANTIC_TYPOGRAPHY, VSCODE_TYPOGRAPHY } from '@/lib/typography';
import { isVSCodeRuntime } from '@/lib/desktop';
const hexToRgb = (value: string | undefined | null): string | null => {
if (!value || typeof value !== 'string') {
@@ -525,22 +526,23 @@ export class CSSVariableGenerator {
private generateTypographyVariables(): string[] {
const vars: string[] = [];
const typography = isVSCodeRuntime() ? VSCODE_TYPOGRAPHY : SEMANTIC_TYPOGRAPHY;
vars.push(' /* Semantic Typography Variables */');
vars.push(' --ui-regular-font-weight: 400;');
vars.push(' /* Markdown content - all markdown elements use same size */');
vars.push(` --text-markdown: ${SEMANTIC_TYPOGRAPHY.markdown};`);
vars.push(` --text-markdown: ${typography.markdown};`);
vars.push(' /* Code content - all code elements use same size */');
vars.push(` --text-code: ${SEMANTIC_TYPOGRAPHY.code};`);
vars.push(` --text-code: ${typography.code};`);
vars.push(' /* UI headers - dialog titles, panel headers */');
vars.push(` --text-ui-header: ${SEMANTIC_TYPOGRAPHY.uiHeader};`);
vars.push(` --text-ui-header: ${typography.uiHeader};`);
vars.push(' /* UI labels - buttons, menus, navigation */');
vars.push(` --text-ui-label: ${SEMANTIC_TYPOGRAPHY.uiLabel};`);
vars.push(` --text-ui-label: ${typography.uiLabel};`);
vars.push(' /* Metadata - timestamps, status, helper text */');
vars.push(` --text-meta: ${SEMANTIC_TYPOGRAPHY.meta};`);
vars.push(` --text-meta: ${typography.meta};`);
vars.push(' /* Micro text - badges, shortcuts, indicators */');
vars.push(` --text-micro: ${SEMANTIC_TYPOGRAPHY.micro};`);
vars.push(` --text-micro: ${typography.micro};`);
vars.push(' /* Heading line height and letter spacing */');
vars.push(' --h1-line-height: 1.25rem;');
+268
View File
@@ -0,0 +1,268 @@
import type { Theme } from '@/types/theme';
import type { ThemeMode } from '@/types/theme';
import { flexokiDarkTheme, flexokiLightTheme } from '@/lib/theme/themes';
export type VSCodeThemeKind = 'light' | 'dark' | 'high-contrast';
export type VSCodeThemeColorToken =
| 'editor.background'
| 'editor.foreground'
| 'editor.selectionBackground'
| 'editor.selectionForeground'
| 'editor.lineHighlightBackground'
| 'editorCursor.foreground'
| 'focusBorder'
| 'sideBar.background'
| 'sideBar.foreground'
| 'panel.background'
| 'panel.foreground'
| 'panel.border'
| 'input.background'
| 'input.foreground'
| 'input.border'
| 'button.background'
| 'button.foreground'
| 'button.hoverBackground'
| 'textLink.foreground'
| 'descriptionForeground'
| 'terminal.ansiRed'
| 'terminal.ansiGreen'
| 'terminal.ansiBlue'
| 'terminal.ansiYellow'
| 'terminal.ansiCyan'
| 'editorError.foreground'
| 'editorError.background'
| 'editorWarning.foreground'
| 'editorWarning.background'
| 'editorInfo.foreground'
| 'editorInfo.background'
| 'testing.iconPassed'
| 'badge.background'
| 'badge.foreground'
| 'statusBar.background'
| 'statusBar.foreground'
| 'list.hoverBackground'
| 'list.activeSelectionBackground'
| 'textPreformat.foreground'
| 'textPreformat.background';
export type VSCodeThemePalette = {
kind: VSCodeThemeKind;
colors: Partial<Record<VSCodeThemeColorToken, string>>;
mode?: ThemeMode;
};
export type VSCodeThemePayload = {
theme: Theme;
palette: VSCodeThemePalette;
};
const VARIABLE_MAP: Record<VSCodeThemeColorToken, string> = {
'editor.background': '--vscode-editor-background',
'editor.foreground': '--vscode-editor-foreground',
'editor.selectionBackground': '--vscode-editor-selectionBackground',
'editor.selectionForeground': '--vscode-editor-selectionForeground',
'editor.lineHighlightBackground': '--vscode-editor-lineHighlightBackground',
'editorCursor.foreground': '--vscode-editorCursor-foreground',
focusBorder: '--vscode-focusBorder',
'sideBar.background': '--vscode-sideBar-background',
'sideBar.foreground': '--vscode-sideBar-foreground',
'panel.background': '--vscode-panel-background',
'panel.foreground': '--vscode-panel-foreground',
'panel.border': '--vscode-panel-border',
'input.background': '--vscode-input-background',
'input.foreground': '--vscode-input-foreground',
'input.border': '--vscode-input-border',
'button.background': '--vscode-button-background',
'button.foreground': '--vscode-button-foreground',
'button.hoverBackground': '--vscode-button-hoverBackground',
'textLink.foreground': '--vscode-textLink-foreground',
descriptionForeground: '--vscode-descriptionForeground',
'terminal.ansiRed': '--vscode-terminal-ansiRed',
'terminal.ansiGreen': '--vscode-terminal-ansiGreen',
'terminal.ansiBlue': '--vscode-terminal-ansiBlue',
'terminal.ansiYellow': '--vscode-terminal-ansiYellow',
'terminal.ansiCyan': '--vscode-terminal-ansiCyan',
'editorError.foreground': '--vscode-editorError-foreground',
'editorError.background': '--vscode-editorError-background',
'editorWarning.foreground': '--vscode-editorWarning-foreground',
'editorWarning.background': '--vscode-editorWarning-background',
'editorInfo.foreground': '--vscode-editorInfo-foreground',
'editorInfo.background': '--vscode-editorInfo-background',
'testing.iconPassed': '--vscode-testing-iconPassed',
'badge.background': '--vscode-badge-background',
'badge.foreground': '--vscode-badge-foreground',
'statusBar.background': '--vscode-statusBar-background',
'statusBar.foreground': '--vscode-statusBar-foreground',
'list.hoverBackground': '--vscode-list-hoverBackground',
'list.activeSelectionBackground': '--vscode-list-activeSelectionBackground',
'textPreformat.foreground': '--vscode-textPreformat-foreground',
'textPreformat.background': '--vscode-textPreformat-background',
};
const normalizeColor = (value?: string | null): string | undefined => {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
return trimmed;
};
const readKind = (preferred?: VSCodeThemeKind): VSCodeThemeKind => {
if (preferred === 'light' || preferred === 'dark' || preferred === 'high-contrast') {
return preferred;
}
if (typeof window !== 'undefined') {
const prefersLight = typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-color-scheme: light)').matches;
return prefersLight ? 'light' : 'dark';
}
return 'dark';
};
export const readVSCodeThemePalette = (
preferredKind?: VSCodeThemeKind,
preferredMode?: ThemeMode,
): VSCodeThemePalette | null => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return null;
}
const styles = getComputedStyle(document.documentElement);
const colors: Partial<Record<VSCodeThemeColorToken, string>> = {};
(Object.keys(VARIABLE_MAP) as VSCodeThemeColorToken[]).forEach((token) => {
const cssVar = VARIABLE_MAP[token];
const value = normalizeColor(styles.getPropertyValue(cssVar));
if (value) {
colors[token] = value;
}
});
return {
kind: readKind(preferredKind),
colors,
mode: preferredMode,
};
};
export const buildVSCodeThemeFromPalette = (palette: VSCodeThemePalette): Theme => {
const base = palette.kind === 'light' ? flexokiLightTheme : flexokiDarkTheme;
const read = (token: VSCodeThemeColorToken, fallback: string): string =>
palette.colors[token] ?? fallback;
const sidebarBg = read('sideBar.background', base.colors.surface.background);
const sidebarFg = read('sideBar.foreground', read('descriptionForeground', base.colors.surface.mutedForeground));
const panelBg = read('panel.background', read('editor.background', base.colors.surface.elevated));
const panelFg = read('panel.foreground', read('editor.foreground', base.colors.surface.foreground));
const background = sidebarBg;
const foreground = read('editor.foreground', base.colors.surface.foreground);
const accent = read('textLink.foreground', read('button.background', base.colors.primary.base));
const accentFg = read('button.foreground', base.colors.primary.foreground || base.colors.surface.background);
const hoverBg = read('list.hoverBackground', read('editor.selectionBackground', base.colors.interactive.hover));
const activeBg = read('list.activeSelectionBackground', hoverBg);
const selection = read('editor.selectionBackground', activeBg);
const selectionFg = read('editor.selectionForeground', foreground);
const focus = read('focusBorder', selection);
const border = read('input.border', read('panel.border', base.colors.interactive.border));
const cursor = read('editorCursor.foreground', base.colors.interactive.cursor);
const badgeBg = read('badge.background', accent);
const badgeFg = read('badge.foreground', foreground);
const inlineCode = read('textPreformat.foreground', read('terminal.ansiGreen', base.colors.syntax.base.string));
// Tailwind's `--accent` drives hovered/selected menu items in Radix/shadcn; prefer VS Code list hover/selection.
const subtle = hoverBg;
return {
...base,
metadata: {
...base.metadata,
id: 'vscode-auto',
name: 'VS Code Theme',
description: 'Mirrors your current VS Code color theme',
author: 'VS Code',
version: '1.0.0',
variant: palette.kind === 'light' ? 'light' : 'dark',
tags: ['vscode', 'auto'],
},
colors: {
...base.colors,
primary: {
base: accent,
hover: read('button.hoverBackground', accent),
active: read('button.hoverBackground', accent),
foreground: accentFg,
muted: read('textLink.foreground', accent),
emphasis: accent,
},
surface: {
...base.colors.surface,
background,
foreground,
muted: panelBg,
mutedForeground: sidebarFg,
elevated: panelBg,
elevatedForeground: panelFg,
overlay: read('statusBar.background', base.colors.surface.overlay),
subtle,
},
interactive: {
...base.colors.interactive,
border,
borderHover: border,
borderFocus: focus,
selection,
selectionForeground: selectionFg,
focus,
focusRing: focus,
cursor,
hover: hoverBg,
active: activeBg,
},
status: {
...base.colors.status,
error: read('editorError.foreground', base.colors.status.error),
errorForeground: read('editorError.foreground', base.colors.status.errorForeground),
errorBackground: read('editorError.background', base.colors.status.errorBackground),
errorBorder: read('editorError.foreground', base.colors.status.errorBorder),
warning: read('editorWarning.foreground', base.colors.status.warning),
warningForeground: read('editorWarning.foreground', base.colors.status.warningForeground),
warningBackground: read('editorWarning.background', base.colors.status.warningBackground),
warningBorder: read('editorWarning.foreground', base.colors.status.warningBorder),
success: read('testing.iconPassed', base.colors.status.success),
successForeground: read('testing.iconPassed', base.colors.status.successForeground),
successBackground: read('testing.iconPassed', base.colors.status.successBackground),
successBorder: read('testing.iconPassed', base.colors.status.successBorder),
info: read('editorInfo.foreground', base.colors.status.info),
infoForeground: read('editorInfo.foreground', base.colors.status.infoForeground),
infoBackground: read('editorInfo.background', base.colors.status.infoBackground),
infoBorder: read('editorInfo.foreground', base.colors.status.infoBorder),
},
syntax: {
...base.colors.syntax,
base: {
...base.colors.syntax.base,
background,
foreground,
comment: read('editor.lineHighlightBackground', base.colors.syntax.base.comment),
keyword: accent,
string: inlineCode,
number: read('terminal.ansiYellow', base.colors.syntax.base.number),
function: read('terminal.ansiBlue', base.colors.syntax.base.function),
variable: read('terminal.ansiCyan', base.colors.syntax.base.variable),
type: read('terminal.ansiCyan', base.colors.syntax.base.type),
operator: accent,
},
},
badges: {
...(base.colors.badges || {}),
default: {
bg: badgeBg,
fg: badgeFg,
border: border,
},
},
},
};
};
+15 -3
View File
@@ -7,6 +7,15 @@ export const SEMANTIC_TYPOGRAPHY = {
micro: '0.875rem',
} as const;
export const VSCODE_TYPOGRAPHY = {
markdown: '0.9375rem',
code: '0.9375rem',
uiHeader: '1rem',
uiLabel: '0.9375rem',
meta: '0.9375rem',
micro: '0.875rem',
} as const;
export const SEMANTIC_TYPOGRAPHY_CSS = {
'--text-markdown': SEMANTIC_TYPOGRAPHY.markdown,
'--text-code': SEMANTIC_TYPOGRAPHY.code,
@@ -247,7 +256,8 @@ export const toolDisplayStyles = {
getCollapsedStyles: () => ({
...typography.tool.collapsed,
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
margin: 0,
padding: toolDisplayStyles.padding.collapsed,
borderRadius: 0,
@@ -255,7 +265,8 @@ export const toolDisplayStyles = {
getPopupStyles: () => ({
...typography.tool.popup,
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
margin: 0,
padding: toolDisplayStyles.padding.popup,
borderRadius: '0.75rem',
@@ -263,7 +274,8 @@ export const toolDisplayStyles = {
getPopupContainerStyles: () => ({
...typography.tool.popup,
background: 'transparent !important',
background: 'transparent',
backgroundColor: 'transparent',
margin: 0,
padding: toolDisplayStyles.padding.popupContainer,
borderRadius: '0.5rem',
+19 -5
View File
@@ -2,15 +2,29 @@ import { SEMANTIC_TYPOGRAPHY } from '@/lib/typography';
let started = false;
const TYPOGRAPHY_STYLE_ID = 'openchamber-typography-base';
const applySemanticTypography = (): void => {
if (typeof document === 'undefined') {
return;
}
const root = document.documentElement;
Object.entries(SEMANTIC_TYPOGRAPHY).forEach(([key, value]) => {
const cssVarName = `--text-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
root.style.setProperty(cssVarName, value);
});
const cssVars = Object.entries(SEMANTIC_TYPOGRAPHY)
.map(([key, value]) => ` --text-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${value};`)
.join('\n');
const styleContent = `:root {\n${cssVars}\n}\n`;
const existing = document.getElementById(TYPOGRAPHY_STYLE_ID);
if (existing) {
existing.textContent = styleContent;
return;
}
const style = document.createElement('style');
style.id = TYPOGRAPHY_STYLE_ID;
style.textContent = styleContent;
document.head.appendChild(style);
};
export const startTypographyWatcher = (): void => {
+46 -13
View File
@@ -7,7 +7,9 @@ import type {
GitIdentitySummary,
} from '@/lib/api/types';
const GIT_POLL_INTERVAL = 3000;
const GIT_POLL_BASE_INTERVAL = 10000;
const GIT_POLL_MAX_INTERVAL = 20000;
const GIT_POLL_BACKOFF_STEP = 5000;
const LOG_STALE_THRESHOLD = 30000;
interface DirectoryGitState {
@@ -34,7 +36,8 @@ interface GitStore {
isLoadingBranches: boolean;
isLoadingIdentity: boolean;
pollIntervalId: ReturnType<typeof setInterval> | null;
pollIntervalId: ReturnType<typeof setTimeout> | null;
currentPollInterval: number;
setActiveDirectory: (directory: string | null) => void;
getDirectoryState: (directory: string) => DirectoryGitState | null;
@@ -139,6 +142,7 @@ export const useGitStore = create<GitStore>()(
isLoadingBranches: false,
isLoadingIdentity: false,
pollIntervalId: null,
currentPollInterval: GIT_POLL_BASE_INTERVAL,
setActiveDirectory: (directory) => {
const { activeDirectory, directories } = get();
@@ -346,24 +350,53 @@ export const useGitStore = create<GitStore>()(
const { pollIntervalId } = get();
if (pollIntervalId) return;
const intervalId = setInterval(async () => {
const { activeDirectory } = get();
if (!activeDirectory) return;
const schedulePoll = () => {
const { currentPollInterval } = get();
const timeoutId = setTimeout(async () => {
// Skip if tab not visible
if (typeof document !== 'undefined' && document.hidden) {
set({ pollIntervalId: schedulePoll() });
return;
}
const statusChanged = await get().fetchStatus(activeDirectory, git, { silent: true });
if (statusChanged) {
await get().fetchLog(activeDirectory, git);
}
}, GIT_POLL_INTERVAL);
const { activeDirectory } = get();
if (!activeDirectory) {
set({ pollIntervalId: schedulePoll() });
return;
}
set({ pollIntervalId: intervalId });
const statusChanged = await get().fetchStatus(activeDirectory, git, { silent: true });
if (statusChanged) {
await get().fetchLog(activeDirectory, git);
// Reset to base interval on changes
set({ currentPollInterval: GIT_POLL_BASE_INTERVAL });
} else {
// Backoff when no changes
const newInterval = Math.min(
currentPollInterval + GIT_POLL_BACKOFF_STEP,
GIT_POLL_MAX_INTERVAL
);
set({ currentPollInterval: newInterval });
}
// Schedule next poll
const { pollIntervalId: currentId } = get();
if (currentId !== null) {
set({ pollIntervalId: schedulePoll() });
}
}, currentPollInterval);
return timeoutId;
};
set({ pollIntervalId: schedulePoll(), currentPollInterval: GIT_POLL_BASE_INTERVAL });
},
stopPolling: () => {
const { pollIntervalId } = get();
if (pollIntervalId) {
clearInterval(pollIntervalId);
set({ pollIntervalId: null });
clearTimeout(pollIntervalId);
set({ pollIntervalId: null, currentPollInterval: GIT_POLL_BASE_INTERVAL });
}
},