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:
committed by
GitHub
parent
ea6d4c4d43
commit
1231fd773e
+2
-6
@@ -48,7 +48,7 @@
|
||||
"desktop:build": "bun run --cwd packages/desktop build:sidecar && bun run --cwd packages/desktop tauri build",
|
||||
"desktop:lint": "bun run --cwd packages/desktop lint && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
|
||||
"desktop:type-check": "bun run --cwd packages/desktop type-check && cargo fmt --manifest-path packages/desktop/src-tauri/Cargo.toml -- --check && cargo clippy --manifest-path packages/desktop/src-tauri/Cargo.toml -- -D warnings",
|
||||
"vscode:dev": "bun run --cwd packages/vscode dev",
|
||||
"vscode:dev": "node ./scripts/dev-vscode.mjs",
|
||||
"vscode:build": "bun run --cwd packages/vscode build",
|
||||
"vscode:package": "bun run --cwd packages/vscode package",
|
||||
"vscode:type-check": "bun run --cwd packages/vscode type-check",
|
||||
@@ -89,7 +89,7 @@
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "^1.2.27",
|
||||
"@opencode-ai/sdk": "^1.3.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -105,8 +105,6 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"electron-context-menu": "^4.1.1",
|
||||
"electron-store": "^11.0.2",
|
||||
"express": "^5.1.0",
|
||||
"ghostty-web": "0.3.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
@@ -141,8 +139,6 @@
|
||||
"concurrently": "^9.2.1",
|
||||
"cors": "^2.8.5",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^38.2.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@opencode-ai/sdk": "^1.2.27",
|
||||
"@opencode-ai/sdk": "^1.3.0",
|
||||
"@pierre/diffs": "1.1.0-beta.13",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
@@ -96,8 +96,6 @@
|
||||
"concurrently": "^9.2.1",
|
||||
"cors": "^2.8.5",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^38.2.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -62,6 +62,20 @@ Select code in the editor, right-click, and find the **OpenChamber** submenu:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run vscode:dev
|
||||
```
|
||||
|
||||
`bun run vscode:dev` now starts watchers + opens an Extension Development Host automatically. Webview UI changes use Vite HMR automatically.
|
||||
|
||||
Optional overrides:
|
||||
|
||||
- `OPENCHAMBER_VSCODE_BIN=cursor bun run vscode:dev`
|
||||
- `OPENCHAMBER_VSCODE_DEV_WORKSPACE=/path/to/workspace bun run vscode:dev`
|
||||
- `bun run vscode:dev /path/to/workspace`
|
||||
|
||||
To package manually:
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/vscode build
|
||||
cd packages/vscode && bunx vsce package --no-dependencies
|
||||
```
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"main": "./dist/extension.js",
|
||||
"activationEvents": [
|
||||
"onCommand:openchamber.openSidebar",
|
||||
"onCommand:openchamber.attachExplorerToChat",
|
||||
"onView:openchamber.chatView"
|
||||
],
|
||||
"contributes": {
|
||||
@@ -134,6 +135,11 @@
|
||||
"category": "OpenChamber",
|
||||
"title": "Settings",
|
||||
"icon": "$(settings-gear)"
|
||||
},
|
||||
{
|
||||
"command": "openchamber.attachExplorerToChat",
|
||||
"category": "OpenChamber",
|
||||
"title": "Attach to OpenChamber Chat"
|
||||
}
|
||||
],
|
||||
"submenus": [
|
||||
@@ -149,6 +155,13 @@
|
||||
"group": "navigation"
|
||||
}
|
||||
],
|
||||
"explorer/context": [
|
||||
{
|
||||
"command": "openchamber.attachExplorerToChat",
|
||||
"when": "resourceScheme == file && explorerResourceIsFolder == false",
|
||||
"group": "navigation@50"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "openchamber.openNewSessionInEditor",
|
||||
@@ -210,7 +223,8 @@
|
||||
"build": "bun run build:extension && bun run build:webview",
|
||||
"build:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --minify --main-fields=module,main",
|
||||
"build:webview": "VITE_OPENCODE_URL=/api vite build",
|
||||
"dev": "concurrently -n \"ext,web\" -c \"cyan,magenta\" \"bun run watch:extension\" \"bun run watch:webview\"",
|
||||
"dev": "concurrently -n \"ext,web\" -c \"cyan,magenta\" \"bun run watch:extension\" \"bun run dev:webview\"",
|
||||
"dev:webview": "vite --host localhost --port 5173 --strictPort",
|
||||
"watch:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --watch --sourcemap --main-fields=module,main",
|
||||
"watch:webview": "vite build --watch",
|
||||
"type-check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.webview.json",
|
||||
@@ -229,7 +243,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "^1.2.27",
|
||||
"@opencode-ai/sdk": "^1.3.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"react": "^19.1.1",
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||
import { getWebviewShikiThemes } from './shikiThemes';
|
||||
import { getWebviewHtml } from './webviewHtml';
|
||||
import { openSseProxy } from './sseProxy';
|
||||
import { resolveWebviewDevServerUrl } from './webviewDevServer';
|
||||
|
||||
export class AgentManagerPanelProvider {
|
||||
public static readonly viewType = 'openchamber.agentManager';
|
||||
@@ -16,12 +17,15 @@ export class AgentManagerPanelProvider {
|
||||
private _cachedError?: string;
|
||||
private _sseCounter = 0;
|
||||
private _sseStreams = new Map<string, AbortController>();
|
||||
private readonly _webviewDevServerUrl: string | null;
|
||||
|
||||
constructor(
|
||||
private readonly _context: vscode.ExtensionContext,
|
||||
private readonly _extensionUri: vscode.Uri,
|
||||
private readonly _openCodeManager?: OpenCodeManager
|
||||
) {}
|
||||
) {
|
||||
this._webviewDevServerUrl = resolveWebviewDevServerUrl(this._context);
|
||||
}
|
||||
|
||||
public createOrShow(): void {
|
||||
// If panel exists, reveal it
|
||||
@@ -227,6 +231,7 @@ export class AgentManagerPanelProvider {
|
||||
initialStatus: this._cachedStatus,
|
||||
cliAvailable,
|
||||
panelType: 'agentManager',
|
||||
devServerUrl: this._webviewDevServerUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||
import { getWebviewShikiThemes } from './shikiThemes';
|
||||
import { getWebviewHtml } from './webviewHtml';
|
||||
import { openSseProxy } from './sseProxy';
|
||||
import { resolveWebviewDevServerUrl } from './webviewDevServer';
|
||||
|
||||
export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly viewType = 'openchamber.chatView';
|
||||
@@ -20,12 +21,15 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
private _cachedError?: string;
|
||||
private _sseCounter = 0;
|
||||
private _sseStreams = new Map<string, AbortController>();
|
||||
private readonly _webviewDevServerUrl: string | null;
|
||||
|
||||
constructor(
|
||||
private readonly _context: vscode.ExtensionContext,
|
||||
private readonly _extensionUri: vscode.Uri,
|
||||
private readonly _openCodeManager?: OpenCodeManager
|
||||
) {}
|
||||
) {
|
||||
this._webviewDevServerUrl = resolveWebviewDevServerUrl(this._context);
|
||||
}
|
||||
|
||||
public resolveWebviewView(
|
||||
webviewView: vscode.WebviewView
|
||||
@@ -110,6 +114,27 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
public addFileMentions(paths: string[]) {
|
||||
if (!this._view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanedPaths = paths
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
if (cleanedPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._view.show(true);
|
||||
this._view.webview.postMessage({
|
||||
type: 'command',
|
||||
command: 'addFileMentions',
|
||||
payload: { paths: cleanedPaths },
|
||||
});
|
||||
}
|
||||
|
||||
public createNewSessionWithPrompt(prompt: string) {
|
||||
if (this._view) {
|
||||
// Reveal the webview panel
|
||||
@@ -277,6 +302,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
workspaceFolder,
|
||||
initialStatus,
|
||||
cliAvailable,
|
||||
devServerUrl: this._webviewDevServerUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||
import { getWebviewShikiThemes } from './shikiThemes';
|
||||
import { getWebviewHtml } from './webviewHtml';
|
||||
import { openSseProxy } from './sseProxy';
|
||||
import { resolveWebviewDevServerUrl } from './webviewDevServer';
|
||||
|
||||
type SessionPanelState = {
|
||||
panel: vscode.WebviewPanel;
|
||||
@@ -18,12 +19,15 @@ export class SessionEditorPanelProvider {
|
||||
private _cachedError?: string;
|
||||
private _sseCounter = 0;
|
||||
private _panels = new Map<string, SessionPanelState>();
|
||||
private readonly _webviewDevServerUrl: string | null;
|
||||
|
||||
constructor(
|
||||
private readonly _context: vscode.ExtensionContext,
|
||||
private readonly _extensionUri: vscode.Uri,
|
||||
private readonly _openCodeManager?: OpenCodeManager
|
||||
) {}
|
||||
) {
|
||||
this._webviewDevServerUrl = resolveWebviewDevServerUrl(this._context);
|
||||
}
|
||||
|
||||
public createOrShowNewSession(): void {
|
||||
// Generate unique panel ID for new session drafts
|
||||
@@ -266,6 +270,7 @@ export class SessionEditorPanelProvider {
|
||||
panelType: 'chat',
|
||||
initialSessionId: sessionId ?? undefined,
|
||||
viewMode: 'editor',
|
||||
devServerUrl: this._webviewDevServerUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,50 @@ const guessMimeTypeFromExtension = (ext: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasUriScheme = (value: string): boolean => /^[A-Za-z][A-Za-z\d+.-]*:/.test(value);
|
||||
|
||||
const parseDroppedFileReference = (rawReference: string):
|
||||
| { uri: vscode.Uri }
|
||||
| { skipped: { name: string; reason: string } } => {
|
||||
const trimmed = rawReference.trim().replace(/^['"]+|['"]+$/g, '');
|
||||
if (!trimmed) {
|
||||
return { skipped: { name: rawReference, reason: 'Empty drop reference' } };
|
||||
}
|
||||
|
||||
if (hasUriScheme(trimmed)) {
|
||||
try {
|
||||
const parsed = vscode.Uri.parse(trimmed, true);
|
||||
if (parsed.scheme !== 'file') {
|
||||
return {
|
||||
skipped: {
|
||||
name: trimmed,
|
||||
reason: `Unsupported URI scheme: ${parsed.scheme || 'unknown'}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { uri: parsed };
|
||||
} catch (error) {
|
||||
return {
|
||||
skipped: {
|
||||
name: trimmed,
|
||||
reason: error instanceof Error ? error.message : 'Invalid URI',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(trimmed)) {
|
||||
return {
|
||||
skipped: {
|
||||
name: trimmed,
|
||||
reason: 'Drop reference is not an absolute file path',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { uri: vscode.Uri.file(trimmed) };
|
||||
};
|
||||
|
||||
const readUriAsAttachment = async (
|
||||
uri: vscode.Uri,
|
||||
fallbackName?: string,
|
||||
@@ -1934,24 +1978,13 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
const dedupedUris = Array.from(new Set(uris.map((value) => value.trim())));
|
||||
|
||||
for (const rawUri of dedupedUris) {
|
||||
let uri: vscode.Uri;
|
||||
try {
|
||||
uri = vscode.Uri.parse(rawUri, true);
|
||||
} catch (error) {
|
||||
skipped.push({
|
||||
name: rawUri,
|
||||
reason: error instanceof Error ? error.message : 'Invalid URI',
|
||||
});
|
||||
const parsed = parseDroppedFileReference(rawUri);
|
||||
if ('skipped' in parsed) {
|
||||
skipped.push(parsed.skipped);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (uri.scheme !== 'file') {
|
||||
skipped.push({
|
||||
name: rawUri,
|
||||
reason: `Unsupported URI scheme: ${uri.scheme}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const uri = parsed.uri;
|
||||
|
||||
const name = path.basename(uri.fsPath || uri.path || rawUri);
|
||||
|
||||
|
||||
@@ -269,6 +269,66 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.attachExplorerToChat', async (resource?: vscode.Uri, resources?: vscode.Uri[]) => {
|
||||
const uriCandidates: vscode.Uri[] = [];
|
||||
if (Array.isArray(resources)) {
|
||||
uriCandidates.push(...resources.filter((entry): entry is vscode.Uri => entry instanceof vscode.Uri));
|
||||
}
|
||||
if (resource instanceof vscode.Uri) {
|
||||
uriCandidates.push(resource);
|
||||
}
|
||||
if (uriCandidates.length === 0) {
|
||||
const activeEditorUri = vscode.window.activeTextEditor?.document.uri;
|
||||
if (activeEditorUri) {
|
||||
uriCandidates.push(activeEditorUri);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueUris = Array.from(new Map(uriCandidates.map((uri) => [uri.toString(), uri])).values());
|
||||
const mentionPaths: string[] = [];
|
||||
const skippedEntries: string[] = [];
|
||||
|
||||
for (const uri of uniqueUris) {
|
||||
if (uri.scheme !== 'file') {
|
||||
skippedEntries.push(uri.toString());
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await vscode.workspace.fs.stat(uri);
|
||||
if ((stat.type & vscode.FileType.Directory) !== 0) {
|
||||
skippedEntries.push(vscode.workspace.asRelativePath(uri, false));
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
skippedEntries.push(vscode.workspace.asRelativePath(uri, false));
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = vscode.workspace.asRelativePath(uri, false).replace(/\\/g, '/').trim();
|
||||
if (!relativePath) {
|
||||
skippedEntries.push(uri.fsPath || uri.toString());
|
||||
continue;
|
||||
}
|
||||
mentionPaths.push(relativePath);
|
||||
}
|
||||
|
||||
if (mentionPaths.length === 0) {
|
||||
vscode.window.showWarningMessage('OpenChamber: No file selected to mention');
|
||||
return;
|
||||
}
|
||||
|
||||
await vscode.commands.executeCommand('openchamber.openSidebar');
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
chatViewProvider?.addFileMentions(mentionPaths);
|
||||
|
||||
if (skippedEntries.length > 0) {
|
||||
vscode.window.showInformationMessage('OpenChamber: Some selected entries were skipped (folders or unsupported resources)');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.explain', async () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const DEFAULT_WEBVIEW_DEV_SERVER_URL = 'http://localhost:5173';
|
||||
|
||||
const normalizeUrl = (value: string): string | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
return parsed.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveWebviewDevServerUrl = (context: vscode.ExtensionContext): string | null => {
|
||||
if (context.extensionMode !== vscode.ExtensionMode.Development) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (process.env.OPENCHAMBER_DISABLE_WEBVIEW_HMR === '1') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configured = normalizeUrl(process.env.OPENCHAMBER_VSCODE_WEBVIEW_URL ?? '');
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
return DEFAULT_WEBVIEW_DEV_SERVER_URL;
|
||||
};
|
||||
@@ -13,8 +13,32 @@ export interface WebviewHtmlOptions {
|
||||
panelType?: PanelType;
|
||||
initialSessionId?: string;
|
||||
viewMode?: 'sidebar' | 'editor';
|
||||
devServerUrl?: string | null;
|
||||
}
|
||||
|
||||
const asCspToken = (value: string | null | undefined): string | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const toOrigin = (value: string | null | undefined): string | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const uniqueTokens = (values: Array<string | null | undefined>): string => {
|
||||
return Array.from(new Set(values.map(asCspToken).filter((value): value is string => Boolean(value)))).join(' ');
|
||||
};
|
||||
|
||||
export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
||||
const {
|
||||
webview,
|
||||
@@ -25,10 +49,18 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
||||
panelType = 'chat',
|
||||
initialSessionId,
|
||||
viewMode = 'sidebar',
|
||||
devServerUrl,
|
||||
} = options;
|
||||
|
||||
const scriptPath = vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'assets', 'index.js');
|
||||
const scriptUri = webview.asWebviewUri(scriptPath);
|
||||
const normalizedDevServerUrl = asCspToken(devServerUrl)?.replace(/\/$/, '') ?? null;
|
||||
const devServerOrigin = toOrigin(normalizedDevServerUrl);
|
||||
const styleSrc = uniqueTokens([webview.cspSource, "'unsafe-inline'", devServerOrigin]);
|
||||
const scriptSrc = uniqueTokens([webview.cspSource, "'unsafe-inline'", "'unsafe-eval'", devServerOrigin]);
|
||||
const connectSrc = uniqueTokens(['*', 'ws:', 'wss:', 'http:', 'https:', devServerOrigin]);
|
||||
const imgSrc = uniqueTokens([webview.cspSource, 'data:', 'https:', devServerOrigin]);
|
||||
const fontSrc = uniqueTokens([webview.cspSource, 'data:', devServerOrigin]);
|
||||
|
||||
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
|
||||
|
||||
@@ -45,7 +77,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src ${webview.cspSource} 'unsafe-inline' 'unsafe-eval'; connect-src * ws: wss: http: https:; img-src ${webview.cspSource} data: https:; font-src ${webview.cspSource} data:;">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${styleSrc}; script-src ${scriptSrc}; connect-src ${connectSrc}; img-src ${imgSrc}; font-src ${fontSrc};">
|
||||
<style>
|
||||
html, body, #root { height: 100%; width: 100%; margin: 0; padding: 0; }
|
||||
body {
|
||||
@@ -171,7 +203,105 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script type="module" src="${scriptUri}"></script>
|
||||
<script type="module">
|
||||
const prodEntryUrl = ${JSON.stringify(scriptUri.toString())};
|
||||
const devServerUrl = ${normalizedDevServerUrl ? JSON.stringify(normalizedDevServerUrl) : 'null'};
|
||||
|
||||
const loadProductionBundle = () => {
|
||||
const script = document.createElement('script');
|
||||
script.type = 'module';
|
||||
script.src = prodEntryUrl;
|
||||
document.body.appendChild(script);
|
||||
};
|
||||
|
||||
if (!devServerUrl) {
|
||||
loadProductionBundle();
|
||||
} else {
|
||||
const baseUrl = devServerUrl;
|
||||
|
||||
const statusEl = document.getElementById('loading-status');
|
||||
const setStatus = (text) => {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = text;
|
||||
}
|
||||
};
|
||||
|
||||
const retryDelayMs = 500;
|
||||
let attempt = 0;
|
||||
|
||||
const waitForRootMount = (timeoutMs) => {
|
||||
const root = document.getElementById('root');
|
||||
if (!root) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
if (root.childNodes.length > 0) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (root.childNodes.length > 0) {
|
||||
observer.disconnect();
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
const timer = window.setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(root.childNodes.length > 0);
|
||||
}, timeoutMs);
|
||||
});
|
||||
};
|
||||
|
||||
const tryLoadDevBundle = () => {
|
||||
const viteClientUrl = baseUrl + '/@vite/client';
|
||||
const reactRefreshUrl = baseUrl + '/@react-refresh';
|
||||
const devEntryUrl = baseUrl + '/main.tsx';
|
||||
const hostLabel = (() => {
|
||||
try {
|
||||
return new URL(baseUrl).host;
|
||||
} catch {
|
||||
return baseUrl;
|
||||
}
|
||||
})();
|
||||
|
||||
setStatus('Starting webview dev server (' + hostLabel + ')...');
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => import(viteClientUrl))
|
||||
.then(() => import(reactRefreshUrl))
|
||||
.then((mod) => {
|
||||
const runtime = mod && mod.default ? mod.default : null;
|
||||
if (runtime && typeof runtime.injectIntoGlobalHook === 'function') {
|
||||
runtime.injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => {};
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
}
|
||||
})
|
||||
.then(() => import(devEntryUrl))
|
||||
.then(() => waitForRootMount(4000))
|
||||
.then((mounted) => {
|
||||
if (!mounted) {
|
||||
throw new Error('Dev bundle loaded but app did not mount');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
attempt += 1;
|
||||
console.warn('[OpenChamber] VS Code webview dev bundle unavailable, retrying...', error);
|
||||
setStatus('Waiting for webview dev server (' + hostLabel + ')... attempt ' + attempt);
|
||||
window.setTimeout(() => {
|
||||
tryLoadDevBundle();
|
||||
}, retryDelayMs);
|
||||
});
|
||||
};
|
||||
|
||||
tryLoadDevBundle();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => ({
|
||||
root: path.resolve(__dirname, 'webview'),
|
||||
base: './', // Use relative paths for VS Code webview
|
||||
plugins: [
|
||||
@@ -27,11 +27,25 @@ export default defineConfig({
|
||||
format: 'es',
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
'process.env.NODE_ENV': JSON.stringify(mode === 'production' ? 'production' : 'development'),
|
||||
'global': 'globalThis',
|
||||
'__OPENCHAMBER_WEBVIEW_BUILD_TIME__': JSON.stringify(new Date().toISOString()),
|
||||
},
|
||||
envPrefix: ['VITE_'],
|
||||
server: {
|
||||
host: 'localhost',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
cors: true,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
hmr: {
|
||||
host: 'localhost',
|
||||
protocol: 'ws',
|
||||
port: 5173,
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@opencode-ai/sdk/v2'],
|
||||
},
|
||||
@@ -48,4 +62,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
@@ -982,6 +982,27 @@ onCommand('addToContext', (payload) => {
|
||||
});
|
||||
});
|
||||
|
||||
onCommand('addFileMentions', (payload) => {
|
||||
const rawPaths = Array.isArray((payload as { paths?: unknown[] })?.paths)
|
||||
? (payload as { paths: unknown[] }).paths
|
||||
: [];
|
||||
const paths = rawPaths
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
if (paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mentionText = paths.map((relativePath) => `@${relativePath}`).join(' ');
|
||||
|
||||
import('@/stores/useSessionStore').then(({ useSessionStore }) => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setPendingInputText(mentionText, 'append-inline');
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for createSessionWithPrompt command from extension (Explain, Improve Code)
|
||||
onCommand('createSessionWithPrompt', (payload) => {
|
||||
const { prompt } = payload as { prompt: string };
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "^1.2.27",
|
||||
"@opencode-ai/sdk": "^1.3.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
||||
@@ -12021,6 +12021,17 @@ async function main(options = {}) {
|
||||
|
||||
app.get('/api/git/status', async (req, res) => {
|
||||
const { getStatus, isGitRepository } = await getGitLibraries();
|
||||
|
||||
const extractGitErrorText = (error) => {
|
||||
const message = typeof error?.message === 'string' ? error.message : '';
|
||||
const stderr = typeof error?.stderr === 'string' ? error.stderr : '';
|
||||
const stdout = typeof error?.stdout === 'string' ? error.stdout : '';
|
||||
return [message, stderr, stdout]
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
if (!directory) {
|
||||
@@ -12035,6 +12046,10 @@ async function main(options = {}) {
|
||||
const status = await getStatus(directory);
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
const errorText = extractGitErrorText(error);
|
||||
if (/not a git repository/i.test(errorText)) {
|
||||
return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 });
|
||||
}
|
||||
console.error('Failed to get git status:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get git status' });
|
||||
}
|
||||
|
||||
@@ -563,6 +563,11 @@ const parseGitErrorText = (error) => {
|
||||
.trim();
|
||||
};
|
||||
|
||||
const isNotGitRepositoryError = (error) => {
|
||||
const text = parseGitErrorText(error);
|
||||
return /not a git repository/i.test(text);
|
||||
};
|
||||
|
||||
const runGitCommand = async (cwd, args) => {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(getGitBinary(), args, {
|
||||
@@ -1065,8 +1070,8 @@ export async function isGitRepository(directory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const gitDir = path.join(directoryPath, '.git');
|
||||
return fs.existsSync(gitDir);
|
||||
const result = await runGitCommand(directoryPath, ['rev-parse', '--git-dir']);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
export async function getGlobalIdentity() {
|
||||
@@ -1411,7 +1416,9 @@ export async function getStatus(directory) {
|
||||
rebaseInProgress,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get Git status:', error);
|
||||
if (!isNotGitRepositoryError(error)) {
|
||||
console.error('Failed to get Git status:', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const extensionPath = path.join(repoRoot, 'packages', 'vscode');
|
||||
const useDetachedChildren = process.platform === 'darwin';
|
||||
|
||||
const codeBin = process.env.OPENCHAMBER_VSCODE_BIN || 'code';
|
||||
const workspaceArg = process.argv[2] || process.env.OPENCHAMBER_VSCODE_DEV_WORKSPACE || repoRoot;
|
||||
const workspacePath = path.resolve(workspaceArg);
|
||||
|
||||
const resolveDevServerAddress = () => {
|
||||
const configured = process.env.OPENCHAMBER_VSCODE_WEBVIEW_URL;
|
||||
if (!configured) {
|
||||
return { host: 'localhost', port: 5173 };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(configured);
|
||||
return {
|
||||
host: parsed.hostname || '127.0.0.1',
|
||||
port: Number(parsed.port) || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
};
|
||||
} catch {
|
||||
return { host: 'localhost', port: 5173 };
|
||||
}
|
||||
};
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const probePort = (host, port, timeoutMs = 500) => {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect({ host, port });
|
||||
let settled = false;
|
||||
|
||||
const done = (ok) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(ok);
|
||||
};
|
||||
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.once('connect', () => done(true));
|
||||
socket.once('timeout', () => done(false));
|
||||
socket.once('error', () => done(false));
|
||||
});
|
||||
};
|
||||
|
||||
const waitForPort = async (host, port, timeoutMs, shouldAbort) => {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (shouldAbort()) return false;
|
||||
const ready = await probePort(host, port);
|
||||
if (ready) return true;
|
||||
await sleep(200);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!fs.existsSync(workspacePath)) {
|
||||
console.error(`[dev:vscode] Workspace path not found: ${workspacePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run(label, command, args, options = {}) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env },
|
||||
detached: useDetachedChildren,
|
||||
...options,
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`[dev:vscode] Failed to start ${label}:`, error);
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
function waitForExit(child, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const onExit = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.off('exit', onExit);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function signalChild(child, signal) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (useDetachedChildren && process.platform !== 'win32') {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopChildTree(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
signalChild(child, 'SIGINT');
|
||||
await waitForExit(child, 2500);
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalChild(child, 'SIGTERM');
|
||||
await waitForExit(child, 2500);
|
||||
}
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
signalChild(child, 'SIGKILL');
|
||||
await waitForExit(child, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
const dev = run('vscode dev watchers', 'bun', ['run', '--cwd', 'packages/vscode', 'dev']);
|
||||
|
||||
console.log(`[dev:vscode] Starting extension host with ${codeBin}`);
|
||||
console.log(`[dev:vscode] Workspace: ${workspacePath}`);
|
||||
console.log(`[dev:vscode] Extension: ${extensionPath}`);
|
||||
const { host: devServerHost, port: devServerPort } = resolveDevServerAddress();
|
||||
console.log(`[dev:vscode] Waiting for webview dev server at ${devServerHost}:${devServerPort}`);
|
||||
|
||||
const ready = await waitForPort(devServerHost, devServerPort, 30000, () => shuttingDown || dev.exitCode !== null || dev.signalCode !== null);
|
||||
if (!ready) {
|
||||
console.warn('[dev:vscode] Webview dev server not ready in time, opening extension host anyway');
|
||||
}
|
||||
|
||||
const host = run(
|
||||
'vscode extension host',
|
||||
codeBin,
|
||||
[
|
||||
'--new-window',
|
||||
'--disable-extensions',
|
||||
'--extensionDevelopmentPath',
|
||||
extensionPath,
|
||||
'--wait',
|
||||
workspacePath,
|
||||
],
|
||||
{ detached: false },
|
||||
);
|
||||
|
||||
async function shutdown(exitCode = 0) {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
await Promise.all([stopChildTree(host), stopChildTree(dev)]);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function onChildExit(label) {
|
||||
return (code, signal) => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0 || signal) {
|
||||
console.error(`[dev:vscode] ${label} exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'none'})`);
|
||||
shutdown(typeof code === 'number' ? code : 1).catch(() => process.exit(1));
|
||||
return;
|
||||
}
|
||||
|
||||
shutdown(0).catch(() => process.exit(1));
|
||||
};
|
||||
}
|
||||
|
||||
dev.on('exit', onChildExit('watchers'));
|
||||
host.on('exit', onChildExit('extension host'));
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
shutdown(130).catch(() => process.exit(130));
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
shutdown(143).catch(() => process.exit(143));
|
||||
});
|
||||
|
||||
process.on('SIGHUP', () => {
|
||||
shutdown(129).catch(() => process.exit(129));
|
||||
});
|
||||
Reference in New Issue
Block a user