feat: improve VS Code dev flow and stabilize sidebar/chat behavior (#754)

* fix: improve session sidebar tooltip and truncation behavior

- Keep new-draft tooltip anchored to its trigger button
- Fix minimal-mode worktree/group header text truncation
- Tune minimal-mode right padding to reduce early label clipping

* fix: render reasoning through markdown pipeline

- Use Streamdown rendering for reasoning in live chat mode
- Remove italic styling from reasoning text
- Render expanded reasoning content with MarkdownRenderer

* chore: remove legacy electron dependencies

- Removed unused Electron packages from root and UI manifests
- Deleted obsolete Electron context menu type declaration
- Regenerated lockfile after dependency cleanup

* fix: handle non-repository folders in git status API

- Prevent 500 errors when status is requested outside a valid Git repo
- Improve repository detection using `git rev-parse --git-dir`
- Reduce noisy server logs for expected non-repo status checks

* fix unloaded session chat layout flicker

* fix: reduce noisy TTS status polling

Cache and dedupe TTS status requests, and only check provider availability when the related voice features are enabled so disabled voice setups stay quiet.

* perf: throttle background PR git status refreshes

* fix: improve VS Code Explorer file drop mentions in chat

- Add Explorer context action to insert selected files as @mentions.
- Handle Explorer drag-and-drop to prefill @file mentions instead of attachments.
- Prevent duplicate plain-path text when dropping multiple files.

* fix: deduplicate recent sessions in VS Code sidebar

- Hide sessions from main list when already shown in recent
- Apply dedup only in VS Code runtime
- Keep session search behavior unchanged

* feat: add true HMR dev flow for VS Code extension

- Load VS Code webview from Vite dev server with React refresh preamble
- Add `vscode:dev` runner that starts watchers and opens Extension Development Host
- Update VS Code dev docs and scripts to use the new HMR startup flow

* feat: polish VS Code session sidebar and attachment UX

- Add resizable sessions sidebar in VS Code layout
- Tighten session list spacing and hover behavior in VS Code
- Remove bulk file/image attach success toasts while keeping error toasts
This commit is contained in:
Bohdan Triapitsyn
2026-03-23 23:51:55 +02:00
committed by GitHub
parent ea6d4c4d43
commit 1231fd773e
39 changed files with 1441 additions and 791 deletions
@@ -412,39 +412,58 @@ export const ChatContainer: React.FC = () => {
if (isSessionHydrating && sessionMessages.length === 0 && !streamingMessageId) {
return (
<div
className="relative flex flex-col h-full bg-background gap-0"
className="relative flex flex-col h-full bg-background"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
<div className="flex-1 overflow-y-auto bg-background pt-6">
<div className="space-y-4">
{HYDRATING_SKELETON_ITEMS.map((item) => (
<div key={item.id} className="group w-full">
<div className="chat-message-column">
<div className="space-y-2.5 px-4 py-3">
<div className="space-y-1.5">
{item.toolRows.map((row) => {
return (
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 rounded-full flex-shrink-0" />
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
</div>
);
})}
</div>
<div className="space-y-1.5 pt-1">
<Skeleton className={cn('h-4 rounded-md', item.textWidths[0])} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[1])} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[2])} />
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
)}
aria-hidden={isDesktopExpandedInput}
>
<div className="absolute inset-0 overflow-y-auto overflow-x-hidden bg-background pt-6">
<div className="space-y-4">
{HYDRATING_SKELETON_ITEMS.map((item) => (
<div key={item.id} className="group w-full">
<div className="chat-message-column">
<div className="space-y-2.5 px-4 py-3">
<div className="space-y-1.5">
{item.toolRows.map((row) => {
return (
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 rounded-full flex-shrink-0" />
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
</div>
);
})}
</div>
<div className="space-y-1.5 pt-1">
<Skeleton className={cn('h-4 rounded-md', item.textWidths[0])} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[1])} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[2])} />
</div>
</div>
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
<ChatInput scrollToBottom={scrollToBottom} />
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
)}
>
<ChatInput scrollToBottom={scrollToBottom} />
</div>
</div>
);
}
@@ -456,11 +475,21 @@ export const ChatContainer: React.FC = () => {
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
{!isDesktopExpandedInput ? (
<div className="flex-1 flex items-center justify-center">
<ChatEmptyState />
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
)}
aria-hidden={isDesktopExpandedInput}
>
{!isDesktopExpandedInput ? (
<div className="absolute inset-0 flex items-center justify-center">
<ChatEmptyState />
</div>
) : null}
</div>
) : null}
<div
className={cn(
'relative z-10',
+239 -115
View File
@@ -70,6 +70,103 @@ const MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = [];
const FILE_MENTION_TOKEN = /^@[^\s]+$/;
const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500;
const VS_CODE_DROP_DATA_TYPES = [
'CodeFiles',
'codefiles',
'application/vnd.code.tree',
'application/vnd.code.tree.explorer',
'text/uri-list',
'text/plain',
];
const FILE_URI_PREFIX = 'file://';
const isLikelyAbsolutePath = (value: string): boolean => (
value.startsWith('/')
|| value.startsWith('\\\\')
|| /^[A-Za-z]:[\\/]/.test(value)
);
const toLikelyFileDropReference = (value: string): string | null => {
const trimmed = value.trim().replace(/^['"]+|['"]+$/g, '');
if (!trimmed) {
return null;
}
if (/[\r\n]/.test(trimmed)) {
return null;
}
if (trimmed.toLowerCase().startsWith(FILE_URI_PREFIX)) {
return trimmed;
}
if (isLikelyAbsolutePath(trimmed)) {
return trimmed;
}
return null;
};
const collectStringLeaves = (input: unknown, output: Set<string>, depth = 0): void => {
if (depth > 6 || input == null) {
return;
}
if (typeof input === 'string') {
output.add(input);
return;
}
if (Array.isArray(input)) {
for (const item of input) {
collectStringLeaves(item, output, depth + 1);
}
return;
}
if (typeof input !== 'object') {
return;
}
for (const value of Object.values(input)) {
collectStringLeaves(value, output, depth + 1);
}
};
const parseDroppedFileReferences = (rawPayload: string): string[] => {
const extracted = new Set<string>();
const addCandidatesFromText = (value: string): void => {
const direct = toLikelyFileDropReference(value);
if (direct) {
extracted.add(direct);
return;
}
for (const line of value.split(/\r?\n/)) {
const candidate = toLikelyFileDropReference(line);
if (candidate) {
extracted.add(candidate);
}
}
};
addCandidatesFromText(rawPayload);
try {
const parsed = JSON.parse(rawPayload) as unknown;
const leaves = new Set<string>();
collectStringLeaves(parsed, leaves);
for (const leaf of leaves) {
addCandidatesFromText(leaf);
}
} catch {
// Ignore non-JSON payloads.
}
return Array.from(extracted);
};
const normalizePath = (value?: string | null): string | null => {
if (typeof value !== 'string') {
@@ -119,6 +216,18 @@ const appendWithLineBreaks = (base: string, next: string): string => {
return `${base}${separator}${nextWithTrailingBreaks}`;
};
const appendInlineText = (base: string, next: string): string => {
const nextTrimmed = next.trim();
if (!nextTrimmed) {
return base;
}
if (!base) {
return `${nextTrimmed} `;
}
const separator = /[\s\n]$/.test(base) ? '' : ' ';
return `${base}${separator}${nextTrimmed} `;
};
interface ChatInputProps {
onOpenSettings?: () => void;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
@@ -189,6 +298,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const dropZoneRef = React.useRef<HTMLDivElement>(null);
const suppressNextFileDropTextInsertRef = React.useRef(false);
const suppressNextFileDropTextInsertTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingDroppedAbsolutePathsRef = React.useRef<string[]>([]);
const canAcceptDropRef = React.useRef(false);
const nativeDragInsideDropZoneRef = React.useRef(false);
const mentionRef = React.useRef<FileMentionHandle>(null);
@@ -216,6 +328,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const clearAttachedFiles = useSessionStore((state) => state.clearAttachedFiles);
const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection);
const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText);
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
const pendingInputText = useSessionStore((state) => state.pendingInputText);
const consumePendingSyntheticParts = useSessionStore((state) => state.consumePendingSyntheticParts);
const currentManagementSessionId = useSessionManagementStore((state) => state.currentSessionId);
@@ -713,6 +826,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (!next.trim()) return prev;
return appendWithLineBreaks(prev, next);
});
} else if (pending.mode === 'append-inline') {
setMessage((prev) => appendInlineText(prev, pending.text));
} else {
setMessage(pending.text);
}
@@ -1578,7 +1693,49 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
updateAutocompleteState(nextValue, cursorPosition);
}, [adjustTextareaHeight, message, updateAutocompleteState]);
const clearDropTextSuppression = React.useCallback(() => {
suppressNextFileDropTextInsertRef.current = false;
pendingDroppedAbsolutePathsRef.current = [];
if (suppressNextFileDropTextInsertTimeoutRef.current) {
clearTimeout(suppressNextFileDropTextInsertTimeoutRef.current);
suppressNextFileDropTextInsertTimeoutRef.current = null;
}
}, []);
const scheduleDropTextSuppressionExpiry = React.useCallback(() => {
if (suppressNextFileDropTextInsertTimeoutRef.current) {
clearTimeout(suppressNextFileDropTextInsertTimeoutRef.current);
}
suppressNextFileDropTextInsertTimeoutRef.current = setTimeout(() => {
clearDropTextSuppression();
}, 700);
}, [clearDropTextSuppression]);
const handleBeforeInput = React.useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
if (!isVSCodeRuntime() || !suppressNextFileDropTextInsertRef.current) {
return;
}
const nativeInputEvent = e.nativeEvent as InputEvent | undefined;
if (nativeInputEvent?.inputType === 'insertFromDrop') {
e.preventDefault();
clearDropTextSuppression();
}
}, [clearDropTextSuppression]);
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const nativeInputEvent = e.nativeEvent as InputEvent | undefined;
if (isVSCodeRuntime() && suppressNextFileDropTextInsertRef.current) {
const candidateAbsolutePaths = pendingDroppedAbsolutePathsRef.current;
const isLikelyDropTextInsertion = nativeInputEvent?.inputType === 'insertFromDrop'
|| candidateAbsolutePaths.some((path) => path.length > 0 && e.target.value.includes(path));
if (isLikelyDropTextInsertion) {
clearDropTextSuppression();
return;
}
}
const value = e.target.value;
const cursorPosition = e.target.selectionStart ?? value.length;
@@ -1605,6 +1762,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
updateAutocompleteState(value, cursorPosition);
};
React.useEffect(() => {
return () => {
clearDropTextSuppression();
};
}, [clearDropTextSuppression]);
const handlePaste = React.useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const fileMap = new Map<string, File>();
@@ -1639,25 +1802,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
insertTextAtSelection(pastedText);
}
let attachedCount = 0;
for (const file of imageFiles) {
const sizeBefore = useSessionStore.getState().attachedFiles.length;
try {
await addAttachedFile(file);
const sizeAfter = useSessionStore.getState().attachedFiles.length;
if (sizeAfter > sizeBefore) {
attachedCount += 1;
}
} catch (error) {
console.error('Clipboard image attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach image from clipboard');
}
}
if (attachedCount > 0) {
toast.success(`Attached ${attachedCount} image${attachedCount > 1 ? 's' : ''} from clipboard`);
}
}, [addAttachedFile, currentSessionId, newSessionDraftOpen, insertTextAtSelection]);
const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => {
@@ -1843,12 +1995,26 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (dataTransfer.files && dataTransfer.files.length > 0) return true;
if (dataTransfer.types) {
const types = Array.from(dataTransfer.types);
if (types.includes('Files')) return true;
if (types.includes('text/uri-list')) return true;
const lowerTypes = types.map((type) => type.toLowerCase());
if (lowerTypes.includes('files')) return true;
if (lowerTypes.includes('text/uri-list')) return true;
if (lowerTypes.includes('codefiles')) return true;
if (lowerTypes.some((type) => type.includes('vnd.code.tree'))) return true;
}
const uriList = dataTransfer.getData('text/uri-list') || dataTransfer.getData('text/plain');
return typeof uriList === 'string' && uriList.toLowerCase().includes('file://');
for (const dataType of VS_CODE_DROP_DATA_TYPES) {
let payload = '';
try {
payload = dataTransfer.getData(dataType);
} catch {
continue;
}
if (payload && parseDroppedFileReferences(payload).length > 0) {
return true;
}
}
return false;
}, []);
const collectDroppedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): File[] => {
@@ -1870,83 +2036,26 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const collectDroppedFileUris = React.useCallback((dataTransfer: DataTransfer | null | undefined): string[] => {
if (!dataTransfer || typeof dataTransfer.getData !== 'function') return [];
const rawUriList = dataTransfer.getData('text/uri-list') || dataTransfer.getData('text/plain');
if (!rawUriList) return [];
const extracted = new Set<string>();
const candidates = rawUriList
.split(/\r?\n/)
.map((value) => value.trim())
.filter((value) => value.length > 0 && !value.startsWith('#'))
.filter((value) => value.toLowerCase().startsWith('file://'));
return Array.from(new Set(candidates));
}, []);
const attachVSCodeDroppedUris = React.useCallback(async (uris: string[]) => {
if (uris.length === 0) return;
try {
const response = await fetch('/api/vscode/drop-files', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ uris }),
});
if (!response.ok) {
throw new Error(`Failed to attach dropped files (${response.status})`);
for (const dataType of VS_CODE_DROP_DATA_TYPES) {
let rawPayload = '';
try {
rawPayload = dataTransfer.getData(dataType);
} catch {
continue;
}
if (!rawPayload) {
continue;
}
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((entry: { name?: string; reason?: string }) => `${entry?.name || 'file'}: ${entry?.reason || 'skipped'}`)
.join('\n');
toast.error(`Some dropped files were skipped:\n${summary}`);
for (const candidate of parseDroppedFileReferences(rawPayload)) {
extracted.add(candidate);
}
let attachedCount = 0;
for (const file of picked as Array<{ name: string; mimeType?: string; dataUrl?: string }>) {
if (!file?.dataUrl) continue;
const sizeBefore = useSessionStore.getState().attachedFiles.length;
try {
const [meta, base64] = file.dataUrl.split(',');
const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream');
if (!base64) continue;
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 });
const localFile = new File([blob], file.name || 'file', { type: mime });
await addAttachedFile(localFile);
const sizeAfter = useSessionStore.getState().attachedFiles.length;
if (sizeAfter > sizeBefore) {
attachedCount += 1;
}
} catch (error) {
console.error('Dropped file attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach dropped file');
}
}
if (attachedCount > 0) {
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
}
} catch (error) {
console.error('VS Code dropped file attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach dropped files');
}
}, [addAttachedFile]);
return Array.from(extracted);
}, []);
const normalizeDroppedPath = React.useCallback((rawPath: string): string => {
const input = rawPath.trim();
@@ -1986,6 +2095,24 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
return normalizedAbsolutePath;
}, [chatSearchDirectory]);
const addVSCodeDroppedUrisAsMentions = React.useCallback((uris: string[]) => {
if (uris.length === 0) return;
const mentions = Array.from(new Set(uris
.map((entry) => normalizeDroppedPath(entry))
.map((entry) => toProjectRelativeMentionPath(entry))
.map((entry) => entry.trim().replace(/^\.\//, ''))
.filter((entry) => entry.length > 0)
.map((entry) => `@${entry}`)));
if (mentions.length === 0) {
return;
}
setPendingInputText(mentions.join(' '), 'append-inline');
toast.success(`Added ${mentions.length} file mention${mentions.length > 1 ? 's' : ''}`);
}, [normalizeDroppedPath, setPendingInputText, toProjectRelativeMentionPath]);
const handleDragEnter = (e: React.DragEvent) => {
if (!hasDraggedFiles(e.dataTransfer)) {
return;
@@ -2014,11 +2141,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
e.stopPropagation();
if (e.currentTarget === e.target) {
setIsDragging(false);
clearDropTextSuppression();
}
};
const handleDrop = async (e: React.DragEvent) => {
if (!hasDraggedFiles(e.dataTransfer)) {
const draggedFiles = hasDraggedFiles(e.dataTransfer);
if (!draggedFiles) {
clearDropTextSuppression();
return;
}
e.preventDefault();
@@ -2032,32 +2162,40 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (files.length === 0 && isVSCodeRuntime()) {
const droppedUris = collectDroppedFileUris(e.dataTransfer);
if (droppedUris.length > 0) {
await attachVSCodeDroppedUris(droppedUris);
pendingDroppedAbsolutePathsRef.current = droppedUris
.map((entry) => normalizeDroppedPath(entry))
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
addVSCodeDroppedUrisAsMentions(droppedUris);
} else {
clearDropTextSuppression();
}
return;
}
let attachedCount = 0;
if (files.length > 0) {
for (const file of files) {
const sizeBefore = useSessionStore.getState().attachedFiles.length;
try {
await addAttachedFile(file);
const sizeAfter = useSessionStore.getState().attachedFiles.length;
if (sizeAfter > sizeBefore) {
attachedCount += 1;
}
} catch (error) {
console.error('File attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
}
}
}
clearDropTextSuppression();
};
if (attachedCount > 0) {
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
const handleDropCapture = (e: React.DragEvent) => {
if (!isVSCodeRuntime()) {
return;
}
if (!hasDraggedFiles(e.dataTransfer)) {
return;
}
suppressNextFileDropTextInsertRef.current = true;
scheduleDropTextSuppressionExpiry();
e.preventDefault();
};
// Tauri desktop: handle native file drops via onDragDropEvent
@@ -2118,9 +2256,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
: [];
if (paths.length === 0) return;
let attachedCount = 0;
for (const path of paths) {
const sizeBefore = useSessionStore.getState().attachedFiles.length;
try {
const normalizedPath = normalizeDroppedPath(path);
const fileName = normalizedPath.split(/[\\/]/).pop() || normalizedPath;
@@ -2149,16 +2285,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
await addAttachedFile(file);
const sizeAfter = useSessionStore.getState().attachedFiles.length;
if (sizeAfter > sizeBefore) attachedCount++;
} catch (error) {
console.error('Failed to attach dropped file:', path, error);
toast.error(`Failed to attach ${path.split(/[\\/]/).pop() || 'file'}`);
}
}
if (attachedCount > 0) {
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
}
}
});
@@ -2183,26 +2314,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const fileInputRef = React.useRef<HTMLInputElement>(null);
const attachFiles = React.useCallback(async (files: FileList | File[]) => {
let attachedCount = 0;
const list = Array.isArray(files) ? files : Array.from(files);
for (const file of list) {
const sizeBefore = useSessionStore.getState().attachedFiles.length;
try {
await addAttachedFile(file);
const sizeAfter = useSessionStore.getState().attachedFiles.length;
if (sizeAfter > sizeBefore) {
attachedCount += 1;
}
} catch (error) {
console.error('File attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
}
}
if (attachedCount > 0) {
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
}
}, [addAttachedFile]);
const handleVSCodePickFiles = React.useCallback(async () => {
@@ -3068,6 +3189,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
backgroundColor: currentTheme?.colors?.surface?.subtle,
}}
ref={dropZoneRef}
onDropCapture={handleDropCapture}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -3192,10 +3314,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
data-chat-input="true"
value={message}
onChange={handleTextChange}
onBeforeInput={handleBeforeInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDropCapture={handleDropCapture}
onDrop={handleDrop}
onPointerDownCapture={handleTextareaPointerDownCapture}
onKeyUp={updateAutocompleteOverlayPosition}
@@ -20,24 +20,15 @@ export const FileAttachmentButton = memo(() => {
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
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(file);
const sizeAfter = useSessionStore.getState().attachedFiles.length;
if (sizeAfter > sizeBefore) {
attachedCount++;
}
} catch (error) {
console.error('File attach failed', error);
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>) => {
@@ -1186,16 +1186,16 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
/>
);
} else {
const partText = (part as { text?: string }).text;
if (partText && partText.trim().length > 0) {
rendered.push(
<FadeInOnReveal key={`reasoning-${messageId}-${i}`}>
<div className="my-0.5 text-sm text-muted-foreground/60 italic leading-relaxed whitespace-pre-wrap">
{partText}
</div>
</FadeInOnReveal>
);
}
rendered.push(
<AssistantTextPart
key={`reasoning-${messageId}-${i}`}
part={part}
messageId={messageId}
streamPhase={streamPhase}
chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
/>
);
}
}
i++;
@@ -7,6 +7,7 @@ import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useUIStore } from '@/stores/useUIStore';
import { useDurationTickerNow } from './useDurationTicker';
import { MarkdownRenderer } from '../../MarkdownRenderer';
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
@@ -78,6 +79,7 @@ type ReasoningTimelineBlockProps = {
blockId: string;
time?: { start?: number; end?: number };
showDuration?: boolean;
isStreaming?: boolean;
};
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
@@ -87,6 +89,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
blockId,
time,
showDuration = true,
isStreaming = false,
}) => {
const [isExpanded, setIsExpanded] = React.useState(false);
@@ -140,7 +143,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
{(summary || (showDuration && typeof timeStart === 'number')) ? (
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta text-muted-foreground/70">
{summary ? <span className="flex-1 min-w-0 truncate italic">{summary}</span> : null}
{summary ? <span className="flex-1 min-w-0 truncate">{summary}</span> : null}
{showDuration && typeof timeStart === 'number' ? (
<span className="relative flex-shrink-0 tabular-nums text-right">
<span className="text-muted-foreground/80 transition-opacity duration-150">
@@ -163,11 +166,17 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
)}
>
<ScrollableOverlay
as="blockquote"
as="div"
outerClassName="max-h-80"
className="whitespace-pre-wrap break-words typography-meta italic text-muted-foreground/70 p-0"
className="p-0"
>
{text}
<MarkdownRenderer
content={text}
messageId={blockId}
isAnimated={false}
isStreaming={isStreaming}
variant="reasoning"
/>
</ScrollableOverlay>
</div>
)}
@@ -191,6 +200,7 @@ const ReasoningPart: React.FC<ReasoningPartProps> = ({
const rawText = partWithText.text || partWithText.content || '';
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
const time = partWithText.time;
const isStreaming = chatRenderMode === 'live' && typeof time?.end !== 'number';
// Show reasoning even if time.end isn't set yet (during streaming)
// Only hide if there's no text content
@@ -206,6 +216,7 @@ const ReasoningPart: React.FC<ReasoningPartProps> = ({
blockId={part.id || `${messageId}-reasoning`}
time={time}
showDuration={chatRenderMode !== 'sorted'}
isStreaming={isStreaming}
/>
);
};
@@ -43,6 +43,8 @@ const MOBILE_WIDTH_THRESHOLD = 550;
const EXPANDED_LAYOUT_THRESHOLD = 1400;
// Sessions sidebar width in expanded layout
const SESSIONS_SIDEBAR_WIDTH = 280;
const SESSIONS_SIDEBAR_MIN_WIDTH = Math.round(SESSIONS_SIDEBAR_WIDTH * 0.7);
const SESSIONS_SIDEBAR_MAX_WIDTH = 520;
type VSCodeView = 'sessions' | 'chat' | 'settings';
@@ -80,7 +82,12 @@ export const VSCodeLayout: React.FC = () => {
const [currentView, setCurrentView] = React.useState<VSCodeView>(() => (bootDraftOpen ? 'chat' : 'sessions'));
const [containerWidth, setContainerWidth] = React.useState<number>(0);
const [expandedSidebarWidth, setExpandedSidebarWidth] = React.useState<number>(SESSIONS_SIDEBAR_WIDTH);
const [isResizingExpandedSidebar, setIsResizingExpandedSidebar] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
const expandedSidebarResizeStartXRef = React.useRef(0);
const expandedSidebarResizeStartWidthRef = React.useRef(SESSIONS_SIDEBAR_WIDTH);
const expandedSidebarResizePointerIdRef = React.useRef<number | null>(null);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessionStore((state) => state.sessions);
@@ -357,6 +364,45 @@ export const VSCodeLayout: React.FC = () => {
const usesMobileLayout = containerWidth > 0 && containerWidth < MOBILE_WIDTH_THRESHOLD;
const usesExpandedLayout = containerWidth >= EXPANDED_LAYOUT_THRESHOLD;
const clampExpandedSidebarWidth = React.useCallback((value: number) => {
return Math.min(SESSIONS_SIDEBAR_MAX_WIDTH, Math.max(SESSIONS_SIDEBAR_MIN_WIDTH, value));
}, []);
const handleExpandedSidebarResizeStart = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore
}
expandedSidebarResizePointerIdRef.current = event.pointerId;
expandedSidebarResizeStartXRef.current = event.clientX;
expandedSidebarResizeStartWidthRef.current = expandedSidebarWidth;
setIsResizingExpandedSidebar(true);
event.preventDefault();
}, [expandedSidebarWidth]);
const handleExpandedSidebarResizeMove = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (expandedSidebarResizePointerIdRef.current !== event.pointerId) {
return;
}
const delta = event.clientX - expandedSidebarResizeStartXRef.current;
const nextWidth = clampExpandedSidebarWidth(expandedSidebarResizeStartWidthRef.current + delta);
setExpandedSidebarWidth((current) => (current === nextWidth ? current : nextWidth));
}, [clampExpandedSidebarWidth]);
const handleExpandedSidebarResizeEnd = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (expandedSidebarResizePointerIdRef.current !== event.pointerId) {
return;
}
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
expandedSidebarResizePointerIdRef.current = null;
setIsResizingExpandedSidebar(false);
}, []);
// In expanded layout, always show chat (with sidebar alongside)
// Navigate to chat automatically when expanded layout is enabled and we're on sessions view
React.useEffect(() => {
@@ -392,8 +438,8 @@ export const VSCodeLayout: React.FC = () => {
<div className="flex h-full">
{/* Sessions sidebar */}
<div
className="h-full border-r border-border overflow-hidden flex-shrink-0"
style={{ width: SESSIONS_SIDEBAR_WIDTH }}
className={cn('relative h-full border-r border-border overflow-hidden flex-shrink-0', isResizingExpandedSidebar && 'select-none')}
style={{ width: expandedSidebarWidth, minWidth: expandedSidebarWidth, maxWidth: expandedSidebarWidth }}
>
<SessionSidebar
mobileVariant
@@ -401,6 +447,19 @@ export const VSCodeLayout: React.FC = () => {
hideDirectoryControls
showOnlyMainWorkspace
/>
<div
className={cn(
'absolute right-0 top-0 z-20 h-full w-[3px] cursor-col-resize transition-colors hover:bg-[var(--interactive-border)]/80',
isResizingExpandedSidebar && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handleExpandedSidebarResizeStart}
onPointerMove={handleExpandedSidebarResizeMove}
onPointerUp={handleExpandedSidebarResizeEnd}
onPointerCancel={handleExpandedSidebarResizeEnd}
role="separator"
aria-orientation="vertical"
aria-label="Resize sessions sidebar"
/>
</div>
{/* Chat content */}
<div className="flex-1 flex flex-col min-w-0">
@@ -533,7 +592,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
}, [setQuotaDisplayMode]);
return (
<div className="flex items-center gap-1.5 pl-1 pr-2 py-1 border-b border-border bg-background shrink-0">
<div className="flex items-center gap-1.5 pl-3 pr-2 py-1 border-b border-border bg-background shrink-0">
{showBack && onBack && (
<button
onClick={onBack}
@@ -170,6 +170,11 @@ export const VoiceSettings: React.FC = () => {
}, [isBrowserPreviewPlaying]);
useEffect(() => {
if (!voiceModeEnabled || voiceProvider !== 'openai') {
setIsOpenAIAvailable(openaiApiKey.trim().length > 0);
return;
}
const checkOpenAIAvailability = async () => {
try {
const response = await fetch('/api/tts/status');
@@ -183,9 +188,15 @@ export const VoiceSettings: React.FC = () => {
};
checkOpenAIAvailability();
}, [openaiApiKey]);
}, [openaiApiKey, voiceModeEnabled, voiceProvider]);
useEffect(() => {
if (!voiceModeEnabled) {
setIsSayAvailable(false);
setSayVoices([]);
return;
}
fetch('/api/tts/say/status')
.then(res => res.json())
.then(data => {
@@ -202,7 +213,7 @@ export const VoiceSettings: React.FC = () => {
.catch(() => {
setIsSayAvailable(false);
});
}, []);
}, [voiceModeEnabled]);
const previewVoice = useCallback(async () => {
if (previewAudio) {
@@ -1032,6 +1032,44 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
];
}, [activeNowSessions, sessionSidebarMetaById]);
const recentSessionIds = React.useMemo(() => {
return new Set(activitySections.flatMap((section) => section.items.map((item) => item.node.session.id)));
}, [activitySections]);
const sectionsForSidebarRender = React.useMemo(() => {
if (!isVSCode || hasSessionSearchQuery || recentSessionIds.size === 0) {
return sectionsForRender;
}
const filterNodes = (nodes: SessionNode[]): SessionNode[] => {
return nodes.reduce<SessionNode[]>((acc, node) => {
if (recentSessionIds.has(node.session.id)) {
return acc;
}
const filteredChildren = filterNodes(node.children);
if (filteredChildren.length === node.children.length) {
acc.push(node);
return acc;
}
acc.push({
...node,
children: filteredChildren,
});
return acc;
}, []);
};
return sectionsForRender.map((section) => ({
...section,
groups: section.groups.map((group) => ({
...group,
sessions: filterNodes(group.sessions),
})),
}));
}, [isVSCode, hasSessionSearchQuery, recentSessionIds, sectionsForRender]);
const desktopHeaderActionButtonClass =
'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed';
const mobileHeaderActionButtonClass =
@@ -1203,12 +1241,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}, [prStatusEntries]);
const renderGroupSessions = React.useCallback(
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null) => (
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => (
<SessionGroupSection
group={group}
groupKey={groupKey}
projectId={projectId}
hideGroupLabel={hideGroupLabel}
compactBodyPadding={compactBodyPadding}
hasSessionSearchQuery={hasSessionSearchQuery}
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
groupSearchDataByGroup={groupSearchDataByGroup}
@@ -1363,7 +1402,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<SidebarProjectsList
topContent={topContent}
sectionsForRender={sectionsForRender}
sectionsForRender={sectionsForSidebarRender}
projectSections={projectSections}
activeProjectId={activeProjectId}
showOnlyMainWorkspace={showOnlyMainWorkspace}
@@ -91,6 +91,7 @@ type Props = {
}>;
onToggleCollapsedGroup: (groupKey: string) => void;
dragHandleProps?: SortableDragHandleProps | null;
compactBodyPadding?: boolean;
};
export function SessionGroupSection(props: Props): React.ReactNode {
@@ -132,6 +133,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
prVisualStateByDirectoryBranch,
onToggleCollapsedGroup,
dragHandleProps,
compactBodyPadding = false,
} = props;
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
@@ -373,8 +375,8 @@ export function SessionGroupSection(props: Props): React.ReactNode {
? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7')
: isMinimalMode
? (hasWorktreeDeleteAction
? 'pr-10 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
: 'pr-10')
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
: 'pr-2')
: (hasWorktreeDeleteAction
? 'pr-5 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
: 'pr-5');
@@ -415,8 +417,10 @@ export function SessionGroupSection(props: Props): React.ReactNode {
</SessionFolderDndScope>
);
const groupBodyPaddingClass = compactBodyPadding ? 'pb-2 pl-1' : 'pb-3 pl-4';
if (hideGroupLabel) {
return <div className="oc-group"><div className="oc-group-body pb-3 pl-4">{body}</div></div>;
return <div className="oc-group"><div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div></div>;
}
return (
@@ -438,12 +442,12 @@ export function SessionGroupSection(props: Props): React.ReactNode {
<div
ref={dragHandleProps?.setActivatorNodeRef}
className={cn(
'min-w-0 flex items-start gap-1 pl-0.5 transition-[padding] cursor-grab active:cursor-grabbing',
'min-w-0 flex flex-1 items-start gap-1 overflow-hidden pl-0.5 transition-[padding] cursor-grab active:cursor-grabbing',
groupHeaderRightPadding,
)}
{...(dragHandleProps?.listeners ?? {})}
>
<div className="min-w-0 flex flex-col justify-center gap-0.5">
<div className="min-w-0 flex flex-1 flex-col justify-center gap-0.5 overflow-hidden">
<p className="text-[14px] font-normal truncate text-foreground/92">
{showInlinePrTitle && prIndicator ? (
<span className="inline-flex min-w-0 max-w-full items-center">
@@ -503,17 +507,17 @@ export function SessionGroupSection(props: Props): React.ReactNode {
<span className="ml-1 min-w-0 flex-1 truncate leading-none align-middle">{group.branch}</span>
</span>
) : group.isArchivedBucket ? (
<span className="inline-flex min-w-0 items-center gap-1">
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<RiArchiveLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground group-hover/gh:hidden" />
<span className="hidden text-muted-foreground group-hover/gh:inline-flex h-3.5 w-3.5 items-center justify-center">
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
</span>
</span>
<span className="truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
</span>
) : (!group.isMain || group.worktree) ? (
<span className="inline-flex min-w-0 items-center gap-1">
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<RiGitBranchLine
className="h-3.5 w-3.5 shrink-0 text-muted-foreground group-hover/gh:hidden"
@@ -523,7 +527,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
</span>
</span>
<span className="truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
</span>
) : (
renderHighlightedText(group.label, normalizedSessionSearchQuery)
@@ -659,7 +663,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
</div>
) : null}
</div>
{!isCollapsed ? <div className="oc-group-body pb-3 pl-4">{body}</div> : null}
{!isCollapsed ? <div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div> : null}
</div>
);
}
@@ -152,6 +152,15 @@ export function SessionNodeItem(props: Props): React.ReactNode {
const displayMode = useSessionDisplayStore((state) => state.displayMode);
const isMinimalMode = displayMode === 'minimal';
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const revealOnHoverClass = isVSCode
? 'group-hover:opacity-100 group-hover:pointer-events-auto'
: 'group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto';
const hideOnHoverClass = isVSCode
? 'group-hover:opacity-0'
: 'group-hover:opacity-0 group-focus-within:opacity-0';
const revealPaddingClass = isVSCode
? 'group-hover:pr-5'
: 'group-hover:pr-5 group-focus-within:pr-5';
const suppressNextSelectRef = React.useRef(false);
const session = node.session;
@@ -433,7 +442,9 @@ export function SessionNodeItem(props: Props): React.ReactNode {
}}
className={cn(
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]',
mobileVariant ? 'pr-7' : '',
mobileVariant
? (isVSCode ? revealPaddingClass : 'pr-7')
: '',
)}
>
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
@@ -446,7 +457,7 @@ export function SessionNodeItem(props: Props): React.ReactNode {
'whitespace-nowrap text-right text-[0.72rem] text-muted-foreground/75 transition-opacity duration-150',
isMenuOpen
? 'opacity-0'
: 'group-hover:opacity-0 group-focus-within:opacity-0',
: hideOnHoverClass,
)}>
{sessionCompactUpdatedLabel}
</span>
@@ -458,7 +469,7 @@ export function SessionNodeItem(props: Props): React.ReactNode {
'absolute inset-y-0 right-0 inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
isMenuOpen
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto',
: cn('opacity-0 pointer-events-none', revealOnHoverClass),
)}
aria-label="Session menu"
onClick={handleMenuTriggerClick}
@@ -509,7 +520,12 @@ export function SessionNodeItem(props: Props): React.ReactNode {
e.stopPropagation();
handleSessionDoubleClick();
}}
className={cn('flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]', mobileVariant ? 'pr-7' : 'group-hover:pr-5 group-focus-within:pr-5')}
className={cn(
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]',
mobileVariant
? (isVSCode ? revealPaddingClass : 'pr-7')
: revealPaddingClass
)}
>
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
{inlineStatusMarker}
@@ -550,9 +566,9 @@ export function SessionNodeItem(props: Props): React.ReactNode {
'transition-opacity',
isMenuOpen
? 'opacity-100 pointer-events-auto'
: mobileVariant
: (mobileVariant && !isVSCode)
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto',
: cn('opacity-0 pointer-events-none', revealOnHoverClass),
),
)}>
<DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}>
@@ -564,7 +580,7 @@ export function SessionNodeItem(props: Props): React.ReactNode {
isMinimalMode && !mobileVariant
? (isMenuOpen
? 'h-4 w-4 opacity-100 pointer-events-auto'
: 'h-4 w-4 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto')
: cn('h-4 w-4 opacity-0 pointer-events-none', revealOnHoverClass))
: 'h-6 w-6 opacity-100',
)}
aria-label="Session menu"
@@ -38,7 +38,7 @@ type Props = {
hasSessionSearchQuery: boolean;
emptyState: React.ReactNode;
searchEmptyState: React.ReactNode;
renderGroupSessions: (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null) => React.ReactNode;
renderGroupSessions: (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => React.ReactNode;
homeDirectory: string | null;
collapsedProjects: Set<string>;
hideDirectoryControls: boolean;
@@ -111,7 +111,7 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
const hideGroupLabel = group.id === primaryGroup.id;
return (
<React.Fragment key={groupKey}>
{props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel)}
{props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel, null, true)}
</React.Fragment>
);
});
@@ -290,8 +290,8 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
onNewSession();
}}
className={cn(
'h-6 w-6 rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
mobileVariant ? 'inline-flex items-center justify-center' : isHovered ? 'inline-flex items-center justify-center' : 'hidden',
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
aria-label={isRepo ? 'New draft session' : 'New session'}
>
+12 -5
View File
@@ -132,13 +132,20 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
const setPendingInputText = useSessionStore((s) => s.setPendingInputText);
const messages = useSessionStore((s) => s.messages);
const createSession = useSessionStore((s) => s.createSession);
const { currentProviderId, currentModelId, currentAgentName, voiceProvider, speechRate, speechPitch, speechVolume, sayVoice, browserVoice, openaiVoice, summarizeVoiceConversation, summarizeCharacterThreshold } = useConfigStore();
const { currentProviderId, currentModelId, currentAgentName, voiceModeEnabled, voiceProvider, speechRate, speechPitch, speechVolume, sayVoice, browserVoice, openaiVoice, summarizeVoiceConversation, summarizeCharacterThreshold } = useConfigStore();
const shouldCheckOpenAIAvailability = voiceModeEnabled && voiceProvider === 'openai';
const shouldCheckSayAvailability = voiceModeEnabled && voiceProvider === 'say';
// Server TTS for mobile (bypasses Safari audio restrictions)
const { speak: speakServerTTS, stop: stopServerTTS, isAvailable: isServerTTSAvailable, unlockAudio: unlockServerTTSAudio } = useServerTTS();
const { speak: speakServerTTS, stop: stopServerTTS, isAvailable: isServerTTSAvailable, unlockAudio: unlockServerTTSAudio } = useServerTTS({
enabled: shouldCheckOpenAIAvailability,
});
// macOS Say TTS
const { speak: speakSayTTS, stop: stopSayTTS, isAvailable: isSayTTSAvailable, unlockAudio: unlockSayTTSAudio } = useSayTTS();
const { speak: speakSayTTS, stop: stopSayTTS, isAvailable: isSayTTSAvailable, unlockAudio: unlockSayTTSAudio } = useSayTTS({
enabled: shouldCheckSayAvailability,
});
// Update messages ref when messages change
useEffect(() => {
@@ -7,11 +7,16 @@ import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
const MAX_BACKGROUND_PR_DIRECTORIES = 50;
const MAX_BACKGROUND_PR_DIRECTORIES = 20;
const ACTIVE_DIRECTORY_REFRESH_TTL_MS = 15_000;
const BACKGROUND_DIRECTORY_REFRESH_TTL_MS = 2 * 60_000;
const BRANCH_REFRESH_INTERVAL_MS = 15_000;
const MAX_STATUS_FETCH_PER_TICK = 3;
const MAX_STATUS_FETCH_ON_RESUME = 5;
const STATUS_FETCH_CONCURRENCY = 2;
const PR_EVENTUAL_CONSISTENCY_REFRESH_DELAY_MS = 5_000;
const RESUME_REFRESH_DEBOUNCE_MS = 700;
const RESUME_FORCE_COOLDOWN_MS = 8_000;
const normalizePath = (value?: string | null): string | null => {
if (typeof value !== 'string') {
@@ -47,6 +52,11 @@ type PrTarget = {
remoteName?: string | null;
};
type BranchRefreshOptions = {
forceCurrent?: boolean;
maxFetchCount?: number;
};
const getBranchRefreshTtl = (directory: string, currentDirectory: string | null): number => {
return directory === currentDirectory ? ACTIVE_DIRECTORY_REFRESH_TTL_MS : BACKGROUND_DIRECTORY_REFRESH_TTL_MS;
};
@@ -62,6 +72,56 @@ const hasRepoSignalChanged = (previous: BranchCacheEntry | undefined, next: Bran
|| previous.behind !== next.behind;
};
const prioritizeDirectoriesForFetch = (
directories: string[],
cache: Map<string, BranchCacheEntry>,
currentDirectory: string | null,
): string[] => {
return [...directories].sort((left, right) => {
const leftPriority = left === currentDirectory ? 0 : 1;
const rightPriority = right === currentDirectory ? 0 : 1;
if (leftPriority !== rightPriority) {
return leftPriority - rightPriority;
}
const leftFetchedAt = cache.get(left)?.fetchedAt ?? 0;
const rightFetchedAt = cache.get(right)?.fetchedAt ?? 0;
if (leftFetchedAt !== rightFetchedAt) {
return leftFetchedAt - rightFetchedAt;
}
return left.localeCompare(right);
});
};
const mapWithConcurrency = async <T, R>(
values: T[],
concurrency: number,
mapper: (value: T) => Promise<R>,
): Promise<R[]> => {
if (values.length === 0) {
return [];
}
const safeConcurrency = Math.max(1, Math.min(concurrency, values.length));
const results = new Array<R>(values.length);
let cursor = 0;
const worker = async () => {
while (true) {
const nextIndex = cursor;
cursor += 1;
if (nextIndex >= values.length) {
return;
}
results[nextIndex] = await mapper(values[nextIndex]);
}
};
await Promise.all(Array.from({ length: safeConcurrency }, () => worker()));
return results;
};
const toPrTargets = (cache: Map<string, BranchCacheEntry>, directories: string[]): PrTarget[] => {
const result: PrTarget[] = [];
directories.forEach((directory) => {
@@ -100,6 +160,10 @@ export const useGitHubPrBackgroundTracking = (
const branchCacheRef = React.useRef<Map<string, BranchCacheEntry>>(new Map());
const targetsRef = React.useRef<PrTarget[]>([]);
const burstTimeoutsRef = React.useRef<Map<string, number>>(new Map());
const refreshInFlightRef = React.useRef(false);
const pendingRefreshRef = React.useRef<BranchRefreshOptions | null>(null);
const resumeRefreshTimeoutRef = React.useRef<number | null>(null);
const lastResumeRefreshAtRef = React.useRef(0);
React.useEffect(() => {
branchCacheRef.current = branchCache;
@@ -179,25 +243,37 @@ export const useGitHubPrBackgroundTracking = (
React.useEffect(() => {
let cancelled = false;
const refreshBranches = async (force = false): Promise<PrTarget[]> => {
const refreshBranches = async (options?: BranchRefreshOptions): Promise<PrTarget[]> => {
const forceCurrent = options?.forceCurrent === true;
const maxFetchCount = Math.max(1, options?.maxFetchCount ?? MAX_STATUS_FETCH_PER_TICK);
const now = Date.now();
const directoriesToFetch = candidateDirectories.filter((directory) => {
const dueDirectories = candidateDirectories.filter((directory) => {
const cached = branchCacheRef.current.get(directory);
if (!cached) {
return true;
}
if (force) {
if (forceCurrent && currentDirectory && directory === currentDirectory) {
return true;
}
return now - cached.fetchedAt > getBranchRefreshTtl(directory, currentDirectory);
});
const directoriesToFetch = prioritizeDirectoriesForFetch(
dueDirectories,
branchCacheRef.current,
currentDirectory,
).slice(0, maxFetchCount);
if (directoriesToFetch.length === 0) {
return toPrTargets(branchCacheRef.current, candidateDirectories);
}
const results = await Promise.all(
directoriesToFetch.map(async (directory) => {
const results = await mapWithConcurrency(
directoriesToFetch,
STATUS_FETCH_CONCURRENCY,
async (directory) => {
try {
const status = await git.getGitStatus(directory);
const branch = typeof status.current === 'string' ? status.current.trim() : '';
@@ -211,7 +287,7 @@ export const useGitHubPrBackgroundTracking = (
} catch {
return { directory, branch: null, tracking: null, ahead: 0, behind: 0 };
}
}),
},
);
if (cancelled) {
@@ -281,13 +357,39 @@ export const useGitHubPrBackgroundTracking = (
return toPrTargets(nextCache, candidateDirectories);
};
void refreshBranches();
const runRefresh = async (options?: BranchRefreshOptions): Promise<PrTarget[]> => {
if (refreshInFlightRef.current) {
const previousPending = pendingRefreshRef.current;
pendingRefreshRef.current = {
forceCurrent: Boolean(previousPending?.forceCurrent || options?.forceCurrent),
maxFetchCount: Math.max(
previousPending?.maxFetchCount ?? 1,
options?.maxFetchCount ?? 1,
),
};
return [];
}
refreshInFlightRef.current = true;
try {
return await refreshBranches(options);
} finally {
refreshInFlightRef.current = false;
const pending = pendingRefreshRef.current;
pendingRefreshRef.current = null;
if (pending) {
void runRefresh(pending);
}
}
};
void runRefresh({ forceCurrent: true, maxFetchCount: MAX_STATUS_FETCH_ON_RESUME });
const intervalId = window.setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
return;
}
void refreshBranches();
void runRefresh({ maxFetchCount: MAX_STATUS_FETCH_PER_TICK });
}, BRANCH_REFRESH_INTERVAL_MS);
const refreshOnResume = () => {
@@ -295,31 +397,44 @@ export const useGitHubPrBackgroundTracking = (
return;
}
void refreshBranches(true).then((nextTargets) => {
const currentTargets = nextTargets.length > 0 ? nextTargets : targetsRef.current;
if (currentTargets.length === 0) {
return;
}
const now = Date.now();
if (now - lastResumeRefreshAtRef.current < RESUME_FORCE_COOLDOWN_MS) {
return;
}
const activeTargets = currentDirectory
? currentTargets.filter((target) => target.directory === currentDirectory)
: [];
if (resumeRefreshTimeoutRef.current !== null) {
window.clearTimeout(resumeRefreshTimeoutRef.current);
}
if (activeTargets.length > 0) {
void refreshPrTargets(activeTargets, {
resumeRefreshTimeoutRef.current = window.setTimeout(() => {
resumeRefreshTimeoutRef.current = null;
lastResumeRefreshAtRef.current = Date.now();
void runRefresh({ forceCurrent: true, maxFetchCount: MAX_STATUS_FETCH_ON_RESUME }).then((nextTargets) => {
const currentTargets = nextTargets.length > 0 ? nextTargets : targetsRef.current;
if (currentTargets.length === 0) {
return;
}
const activeTargets = currentDirectory
? currentTargets.filter((target) => target.directory === currentDirectory)
: [];
if (activeTargets.length > 0) {
void refreshPrTargets(activeTargets, {
force: true,
silent: true,
markInitialResolved: true,
});
}
void refreshPrTargets(currentTargets, {
force: true,
onlyExistingPr: true,
silent: true,
markInitialResolved: true,
});
}
void refreshPrTargets(currentTargets, {
force: true,
onlyExistingPr: true,
silent: true,
markInitialResolved: true,
});
});
}, RESUME_REFRESH_DEBOUNCE_MS);
};
window.addEventListener('focus', refreshOnResume);
@@ -330,6 +445,12 @@ export const useGitHubPrBackgroundTracking = (
window.clearInterval(intervalId);
window.removeEventListener('focus', refreshOnResume);
document.removeEventListener('visibilitychange', refreshOnResume);
if (resumeRefreshTimeoutRef.current !== null) {
window.clearTimeout(resumeRefreshTimeoutRef.current);
resumeRefreshTimeoutRef.current = null;
}
pendingRefreshRef.current = null;
refreshInFlightRef.current = false;
};
}, [candidateDirectories, currentDirectory, git, refreshPrTargets, scheduleBurstRefresh]);
+11 -3
View File
@@ -34,10 +34,18 @@ export function useMessageTTS(): UseMessageTTSReturn {
openaiVoice,
summarizeMessageTTS,
summarizeCharacterThreshold,
showMessageTTSButtons,
} = useConfigStore();
const { speak: speakServerTTS, stop: stopServerTTS, isAvailable: isServerTTSAvailable } = useServerTTS();
const { speak: speakSayTTS, stop: stopSayTTS, isAvailable: isSayTTSAvailable } = useSayTTS();
const shouldCheckOpenAIAvailability = showMessageTTSButtons && voiceProvider === 'openai';
const shouldCheckSayAvailability = showMessageTTSButtons && voiceProvider === 'say';
const { speak: speakServerTTS, stop: stopServerTTS, isAvailable: isServerTTSAvailable } = useServerTTS({
enabled: shouldCheckOpenAIAvailability,
});
const { speak: speakSayTTS, stop: stopSayTTS, isAvailable: isSayTTSAvailable } = useSayTTS({
enabled: shouldCheckSayAvailability,
});
const stop = useCallback(() => {
setIsPlaying(false);
+78 -17
View File
@@ -19,6 +19,67 @@
import { useCallback, useEffect, useRef, useState } from 'react';
interface SayTTSStatusCache {
available: boolean;
voices: Array<{ name: string; locale: string }>;
checkedAt: number;
}
interface UseSayTTSOptions {
enabled?: boolean;
}
const SAY_TTS_STATUS_TTL_MS = 30000;
let sayTTSStatusCache: SayTTSStatusCache | null = null;
let sayTTSStatusRequest: Promise<SayTTSStatusCache> | null = null;
async function getSayTTSStatus(): Promise<SayTTSStatusCache> {
const now = Date.now();
if (sayTTSStatusCache && now - sayTTSStatusCache.checkedAt < SAY_TTS_STATUS_TTL_MS) {
return sayTTSStatusCache;
}
if (sayTTSStatusRequest) {
return sayTTSStatusRequest;
}
sayTTSStatusRequest = (async () => {
try {
const response = await fetch('/api/tts/say/status');
if (!response.ok) {
const unavailableStatus: SayTTSStatusCache = {
available: false,
voices: [],
checkedAt: Date.now(),
};
sayTTSStatusCache = unavailableStatus;
return unavailableStatus;
}
const data = await response.json();
const nextStatus: SayTTSStatusCache = {
available: Boolean(data.available),
voices: Array.isArray(data.voices) ? data.voices : [],
checkedAt: Date.now(),
};
sayTTSStatusCache = nextStatus;
return nextStatus;
} catch {
const unavailableStatus: SayTTSStatusCache = {
available: false,
voices: [],
checkedAt: Date.now(),
};
sayTTSStatusCache = unavailableStatus;
return unavailableStatus;
} finally {
sayTTSStatusRequest = null;
}
})();
return sayTTSStatusRequest;
}
export interface UseSayTTSReturn {
/** Whether TTS is currently playing */
isPlaying: boolean;
@@ -61,7 +122,8 @@ function getAudioContext(): AudioContext {
return sharedAudioContext;
}
export function useSayTTS(): UseSayTTSReturn {
export function useSayTTS(options: UseSayTTSOptions = {}): UseSayTTSReturn {
const enabled = options.enabled ?? true;
const [isPlaying, setIsPlaying] = useState(false);
const [isAvailable, setIsAvailable] = useState(false);
const [voices, setVoices] = useState<Array<{ name: string; locale: string }>>([]);
@@ -97,28 +159,27 @@ export function useSayTTS(): UseSayTTSReturn {
// Check if macOS say is available
const checkAvailability = useCallback(async (): Promise<boolean> => {
try {
const response = await fetch('/api/tts/say/status');
if (response.ok) {
const data = await response.json();
setIsAvailable(data.available);
if (data.voices) {
setVoices(data.voices);
}
return data.available;
}
setIsAvailable(false);
return false;
} catch (err) {
console.error('[useSayTTS] Failed to check availability:', err);
if (!enabled) {
setIsAvailable(false);
setVoices([]);
return false;
}
}, []);
try {
const status = await getSayTTSStatus();
setIsAvailable(status.available);
setVoices(status.voices);
return status.available;
} catch {
setIsAvailable(false);
setVoices([]);
return false;
}
}, [enabled]);
// Check availability on mount
useEffect(() => {
checkAvailability();
void checkAvailability();
}, [checkAvailability]);
// Stop current playback
+66 -18
View File
@@ -19,6 +19,52 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useConfigStore } from '@/stores/useConfigStore';
interface ServerTTSStatusCache {
available: boolean;
checkedAt: number;
}
interface UseServerTTSOptions {
enabled?: boolean;
}
const SERVER_TTS_STATUS_TTL_MS = 30000;
let serverTTSStatusCache: ServerTTSStatusCache | null = null;
let serverTTSStatusRequest: Promise<boolean> | null = null;
async function getServerTTSStatus(): Promise<boolean> {
const now = Date.now();
if (serverTTSStatusCache && now - serverTTSStatusCache.checkedAt < SERVER_TTS_STATUS_TTL_MS) {
return serverTTSStatusCache.available;
}
if (serverTTSStatusRequest) {
return serverTTSStatusRequest;
}
serverTTSStatusRequest = (async () => {
try {
const response = await fetch('/api/tts/status');
if (!response.ok) {
serverTTSStatusCache = { available: false, checkedAt: Date.now() };
return false;
}
const data = await response.json();
const available = Boolean(data.available);
serverTTSStatusCache = { available, checkedAt: Date.now() };
return available;
} catch {
serverTTSStatusCache = { available: false, checkedAt: Date.now() };
return false;
} finally {
serverTTSStatusRequest = null;
}
})();
return serverTTSStatusRequest;
}
export interface UseServerTTSReturn {
/** Whether TTS is currently playing */
isPlaying: boolean;
@@ -69,7 +115,8 @@ function getAudioContext(): AudioContext {
return sharedAudioContext;
}
export function useServerTTS(): UseServerTTSReturn {
export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSReturn {
const enabled = options.enabled ?? true;
const [isPlaying, setIsPlaying] = useState(false);
const [isAvailable, setIsAvailable] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -82,29 +129,30 @@ export function useServerTTS(): UseServerTTSReturn {
// Check if server TTS is available
const checkAvailability = useCallback(async (): Promise<boolean> => {
try {
const response = await fetch('/api/tts/status');
if (response.ok) {
const data = await response.json();
// Available if server has key OR user has provided their own key
const hasServerKey = data.available;
const hasClientKey = openaiApiKey && openaiApiKey.trim().length > 0;
const available = hasServerKey || hasClientKey;
setIsAvailable(available);
return available;
}
setIsAvailable(false);
return false;
} catch (err) {
console.error('[useServerTTS] Failed to check availability:', err);
if (!enabled) {
setIsAvailable(false);
return false;
}
}, [openaiApiKey]);
const hasClientKey = Boolean(openaiApiKey && openaiApiKey.trim().length > 0);
if (hasClientKey) {
setIsAvailable(true);
return true;
}
try {
const hasServerKey = await getServerTTSStatus();
setIsAvailable(hasServerKey);
return hasServerKey;
} catch {
setIsAvailable(false);
return false;
}
}, [enabled, openaiApiKey]);
// Check availability on mount and when API key changes
useEffect(() => {
checkAvailability();
void checkAvailability();
}, [checkAvailability]);
// Stop current playback
+1 -2
View File
@@ -785,11 +785,10 @@ html:not(.dark) .chat-scroll {
font-size: var(--text-code) !important;
}
/* Reasoning markdown renders at meta size, dimmed and italic. */
/* Reasoning markdown renders at meta size, dimmed. */
.streamdown-content.streamdown-reasoning {
font-size: var(--text-meta);
color: var(--muted-foreground);
font-style: italic;
}
.streamdown-content.streamdown-reasoning strong,
+3 -3
View File
@@ -200,7 +200,7 @@ export interface SessionStore {
userSummaryTitles: Map<string, { title: string; createdAt: number | null }>;
pendingInputText: string | null;
pendingInputMode: 'replace' | 'append';
pendingInputMode: 'replace' | 'append' | 'append-inline';
/** Synthetic context parts to include with the next message sent */
pendingSyntheticParts: SyntheticContextPart[] | null;
@@ -320,8 +320,8 @@ export interface SessionStore {
handleSlashUndo: (sessionId: string) => Promise<void>;
handleSlashRedo: (sessionId: string) => Promise<void>;
forkFromMessage: (sessionId: string, messageId: string) => Promise<void>;
setPendingInputText: (text: string | null, mode?: 'replace' | 'append') => void;
consumePendingInputText: () => { text: string; mode: 'replace' | 'append' } | null;
setPendingInputText: (text: string | null, mode?: 'replace' | 'append' | 'append-inline') => void;
consumePendingInputText: () => { text: string; mode: 'replace' | 'append' | 'append-inline' } | null;
setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void;
consumePendingSyntheticParts: () => SyntheticContextPart[] | null;
}
+1 -1
View File
@@ -1188,7 +1188,7 @@ export const useSessionStore = create<SessionStore>()(
}
},
setPendingInputText: (text: string | null, mode: 'replace' | 'append' = 'replace') => {
setPendingInputText: (text: string | null, mode: 'replace' | 'append' | 'append-inline' = 'replace') => {
set({ pendingInputText: text, pendingInputMode: mode });
},
-35
View File
@@ -1,35 +0,0 @@
declare module 'electron-context-menu' {
import type { BrowserWindow, MenuItemConstructorOptions } from 'electron';
type DefaultActions = {
[key: string]: (...args: unknown[]) => void;
};
type ContextMenuParams = unknown;
interface ContextMenuOptions {
window?: BrowserWindow;
showInspectElement?: boolean;
showSaveImage?: boolean;
showSaveImageAs?: boolean;
showSaveLinkAs?: boolean;
showCopyLink?: boolean;
showCopyImage?: boolean;
showCopyImageAddress?: boolean;
prepend?: (
defaultActions: DefaultActions,
params: ContextMenuParams,
browserWindow: BrowserWindow | undefined
) => MenuItemConstructorOptions[];
append?: (
defaultActions: DefaultActions,
params: ContextMenuParams,
browserWindow: BrowserWindow | undefined
) => MenuItemConstructorOptions[];
shouldShowMenu?: (event: unknown, params: ContextMenuParams) => boolean;
labels?: Record<string, string>;
[key: string]: unknown;
}
export default function contextMenu(options?: ContextMenuOptions): () => void;
}