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:
committed by
GitHub
parent
610ccf4c62
commit
bb72c0fb0c
+20
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;');
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user