fix: improve cross-runtime session UX and platform config handling (#725)

* fix: make textarea focus highlight render inside

Apply inset focus ring to shared textarea component
Prevent focus border from appearing clipped near container edges

* fix: build desktop sidecar with target-matched architecture

Map Tauri target triples to Bun compile targets
Pass explicit Bun compile target for sidecar builds
Prevent x86_64 releases from shipping arm64 sidecar binaries

* fix: allow Windows git custom binary paths

Enable safe use of resolved custom git executable paths
Prevent git status failures when path contains restricted characters
Keep default behavior unchanged for plain git invocations

* fix: allow toggling diff line wrap on mobile

Stops forcing wrapped lines in mobile diff view
Line-wrap button now reflects and applies user preference

* fix: align VS Code managed server env with shell settings

Import login-shell environment variables before starting managed OpenCode
Apply Windows and Unix shell snapshot resolution for parity
Improve proxy-dependent provider connectivity in VS Code extension

* fix: respect user scope when adding MCP servers

Prevent user-scope MCP entries from being written to project config
Keep project writes only for explicit project scope

* fix: show linked GitHub issues and PRs as user message attachments

Preserve synthetic issue/PR context parts during message filtering.
Convert synthetic GitHub context JSON into attachment-style user parts.
Open issue/PR attachment links via shared external URL helper.

* fix: restore and polish project notes in sessions sidebar

Restored the Notes button in the left sessions sidebar header
Improved notes panel layout with wider dialog, larger notes area, and project name in the header
Refined todo rows with inline expand/collapse text and stable action/checkbox alignment

* fix: hide sidebar footer actions in VS Code runtime

Remove Settings, About, and Shortcuts buttons from the sessions sidebar footer in VS Code
Keep update button behavior unchanged across runtimes

* fix: normalize Windows paths for VS Code session loading

Canonicalize drive-letter casing in session path normalization
Align VS Code workspace path persistence with the same Windows path format
Normalize client directory context before API calls to keep session filtering consistent

* fix: open linked GitHub attachments with shared URL helper

Use runtime-aware external URL opening for issue/PR attachment links.
Keep GitHub attachment labels readable without altering normal file name rendering.

* fix: keep user MCP config writes out of project files

Respect user scope when selecting config write target
Prevent MCP user entries from being written to project opencode.json

* fix: prevent project menu from overlapping new session button

Align project menu positioning for non-git and git project rows
Avoid kebab-menu and plus-button overlap in sessions sidebar
This commit is contained in:
Bohdan Triapitsyn
2026-03-20 18:58:13 +02:00
committed by GitHub
parent b4949c6e33
commit 7356090e3d
20 changed files with 616 additions and 114 deletions
+25 -2
View File
@@ -37,6 +37,23 @@ const inferTargetTriple = () => {
}; };
const targetTriple = inferTargetTriple(); const targetTriple = inferTargetTriple();
const bunCompileTargetByTriple = {
'aarch64-apple-darwin': 'bun-darwin-arm64',
'x86_64-apple-darwin': 'bun-darwin-x64',
'aarch64-unknown-linux-gnu': 'bun-linux-arm64',
'x86_64-unknown-linux-gnu': 'bun-linux-x64',
'x86_64-pc-windows-msvc': 'bun-windows-x64',
};
const compileTarget = bunCompileTargetByTriple[targetTriple];
if (!compileTarget) {
console.warn(
`[desktop] unknown target triple '${targetTriple}', falling back to host-arch sidecar build`
);
}
const sidecarBaseName = process.platform === 'win32' const sidecarBaseName = process.platform === 'win32'
? `openchamber-server-${targetTriple}.exe` ? `openchamber-server-${targetTriple}.exe`
: `openchamber-server-${targetTriple}`; : `openchamber-server-${targetTriple}`;
@@ -96,13 +113,19 @@ await copyDir(webDistDir, resourcesWebDistDir);
console.log('[desktop] building openchamber-server sidecar...'); console.log('[desktop] building openchamber-server sidecar...');
await fs.mkdir(sidecarsDir, { recursive: true }); await fs.mkdir(sidecarsDir, { recursive: true });
run(bunExe, [ const buildArgs = [
'build', 'build',
'--compile', '--compile',
path.join(webDir, 'server', 'index.js'), path.join(webDir, 'server', 'index.js'),
'--outfile', '--outfile',
sidecarOutPath, sidecarOutPath,
], repoRoot); ];
if (compileTarget) {
buildArgs.push('--target', compileTarget);
}
run(bunExe, buildArgs, repoRoot);
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
await fs.chmod(sidecarOutPath, 0o755); await fs.chmod(sidecarOutPath, 0o755);
@@ -1,9 +1,10 @@
import React, { useRef, memo } from 'react'; import React, { useRef, memo } from 'react';
import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine } from '@remixicon/react'; import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiGithubLine, RiGitPullRequestLine } from '@remixicon/react';
import { useSessionStore, type AttachedFile } from '@/stores/useSessionStore'; import { useSessionStore, type AttachedFile } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs'; import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
@@ -313,6 +314,19 @@ interface FilePart {
size?: number; size?: number;
} }
const GITHUB_ISSUE_LINK_MIME = 'application/vnd.github.issue-link';
const GITHUB_PR_LINK_MIME = 'application/vnd.github.pull-request-link';
const getGitHubLinkKind = (file: FilePart): 'issue' | 'pr' | null => {
if (file.mime === GITHUB_ISSUE_LINK_MIME) {
return 'issue';
}
if (file.mime === GITHUB_PR_LINK_MIME) {
return 'pr';
}
return null;
};
interface MessageFilesDisplayProps { interface MessageFilesDisplayProps {
files: FilePart[]; files: FilePart[];
onShowPopup?: (content: ToolPopupContent) => void; onShowPopup?: (content: ToolPopupContent) => void;
@@ -333,6 +347,14 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
return filename || path; return filename || path;
}; };
const resolveDisplayName = (file: FilePart): string => {
const isGitHubLink = getGitHubLinkKind(file) !== null;
if (isGitHubLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
return file.filename.trim();
}
return extractFilename(file.filename || file.url);
};
const formatFileSize = (bytes?: number) => { const formatFileSize = (bytes?: number) => {
if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return ''; if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return '';
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -347,7 +369,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
() => () =>
imageFiles.flatMap((file) => { imageFiles.flatMap((file) => {
if (!file.url) return []; if (!file.url) return [];
const filename = extractFilename(file.filename) || 'Image'; const filename = resolveDisplayName(file) || 'Image';
return [{ return [{
url: file.url, url: file.url,
mimeType: file.mime, mimeType: file.mime,
@@ -397,21 +419,41 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
{otherFiles.length > 0 && ( {otherFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{otherFiles.map((file, index) => { {otherFiles.map((file, index) => {
const fileName = extractFilename(file.filename || file.url); const fileName = resolveDisplayName(file);
const sizeText = formatFileSize(file.size); const sizeText = formatFileSize(file.size);
const githubLinkKind = getGitHubLinkKind(file);
return ( return (
<Tooltip key={`file-${file.url || file.filename || index}`}> <Tooltip key={`file-${file.url || file.filename || index}`}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg"> {githubLinkKind && file.url ? (
{file.mime?.includes('pdf') ? ( <button
<RiFilePdfLine className="text-muted-foreground h-3.5 w-3.5" /> type="button"
) : ( onClick={() => {
<RiFileLine className="text-muted-foreground h-3.5 w-3.5" /> void openExternalUrl(file.url || '');
)} }}
<div className="overflow-hidden max-w-[140px]"> className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg text-foreground hover:text-primary transition-colors"
<span className="truncate block" title={fileName}>{fileName}</span> >
{githubLinkKind === 'pr' ? (
<RiGitPullRequestLine className="text-muted-foreground h-3.5 w-3.5" />
) : (
<RiGithubLine className="text-muted-foreground h-3.5 w-3.5" />
)}
<div className="overflow-hidden max-w-[220px]">
<span className="truncate block" title={fileName}>{fileName}</span>
</div>
</button>
) : (
<div className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg">
{file.mime?.includes('pdf') ? (
<RiFilePdfLine className="text-muted-foreground h-3.5 w-3.5" />
) : (
<RiFileLine className="text-muted-foreground h-3.5 w-3.5" />
)}
<div className="overflow-hidden max-w-[140px]">
<span className="truncate block" title={fileName}>{fileName}</span>
</div>
</div> </div>
</div> )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{fileName}{sizeText ? ` (${sizeText})` : ''}</p> <p>{fileName}{sizeText ? ` (${sizeText})` : ''}</p>
@@ -426,7 +468,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
<div className="overflow-x-auto -mx-1 px-1 py-0.5 scrollbar-thin"> <div className="overflow-x-auto -mx-1 px-1 py-0.5 scrollbar-thin">
<div className="flex snap-x snap-mandatory gap-2"> <div className="flex snap-x snap-mandatory gap-2">
{imageFiles.map((file, index) => { {imageFiles.map((file, index) => {
const filename = extractFilename(file.filename) || 'Image'; const filename = resolveDisplayName(file) || 'Image';
return ( return (
<Tooltip key={`img-${file.url || file.filename || index}`} delayDuration={1000}> <Tooltip key={`img-${file.url || file.filename || index}`} delayDuration={1000}>
@@ -475,9 +517,10 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
compact ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2" compact ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2"
)}> )}>
{fileItems.map((file, index) => { {fileItems.map((file, index) => {
const fileName = extractFilename(file.filename || file.url); const fileName = resolveDisplayName(file);
const isImage = file.mime?.startsWith('image/'); const isImage = file.mime?.startsWith('image/');
const sizeText = formatFileSize(file.size); const sizeText = formatFileSize(file.size);
const githubLinkKind = getGitHubLinkKind(file);
if (isImage && file.url) { if (isImage && file.url) {
return ( return (
@@ -500,6 +543,40 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
); );
} }
if (githubLinkKind && file.url) {
return (
<Tooltip key={index}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
void openExternalUrl(file.url || '');
}}
className={cn(
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left",
compact ? "text-xs" : "text-sm"
)}
>
<div className="flex-shrink-0">
{githubLinkKind === 'pr' ? (
<RiGitPullRequestLine className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
) : (
<RiGithubLine className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{fileName}</p>
{sizeText && <p className="text-xs text-muted-foreground">{sizeText}</p>}
</div>
</button>
</TooltipTrigger>
<TooltipContent>
<p>{fileName}{sizeText ? ` (${sizeText})` : ''}</p>
</TooltipContent>
</Tooltip>
);
}
return ( return (
<Tooltip key={index}> <Tooltip key={index}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -1,5 +1,86 @@
import type { Part } from '@opencode-ai/sdk/v2'; import type { Part } from '@opencode-ai/sdk/v2';
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
type GitHubIssueContextPayload = {
issue?: {
number?: unknown;
title?: unknown;
url?: unknown;
};
};
type GitHubPrContextPayload = {
pr?: {
number?: unknown;
title?: unknown;
url?: unknown;
};
};
const isPositiveNumber = (value: unknown): value is number => {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
};
const parseSyntheticJsonPayload = <T>(text: string, prefix: string): T | null => {
const normalizedText = text.trimStart();
if (!normalizedText.startsWith(prefix)) {
return null;
}
const jsonStart = normalizedText.indexOf('{');
if (jsonStart < 0) {
return null;
}
try {
return JSON.parse(normalizedText.slice(jsonStart)) as T;
} catch {
return null;
}
};
const buildGitHubAttachmentPart = (text: string): Part | null => {
const issuePayload = parseSyntheticJsonPayload<GitHubIssueContextPayload>(text, GITHUB_ISSUE_CONTEXT_PREFIX);
if (issuePayload) {
const issue = issuePayload.issue;
const number = issue?.number;
const title = issue?.title;
const url = issue?.url;
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
return null;
}
return {
type: 'file',
mime: 'application/vnd.github.issue-link',
filename: `Issue #${number}: ${title}`,
url,
} as Part;
}
const prPayload = parseSyntheticJsonPayload<GitHubPrContextPayload>(text, GITHUB_PR_CONTEXT_PREFIX);
if (prPayload) {
const pr = prPayload.pr;
const number = pr?.number;
const title = pr?.title;
const url = pr?.url;
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
return null;
}
return {
type: 'file',
mime: 'application/vnd.github.pull-request-link',
filename: `PR #${number}: ${title}`,
url,
} as Part;
}
return null;
};
const shouldKeepSyntheticUserText = (text: string): boolean => { const shouldKeepSyntheticUserText = (text: string): boolean => {
const trimmed = text.trim(); const trimmed = text.trim();
if (trimmed.startsWith('User has requested to enter plan mode')) return true; if (trimmed.startsWith('User has requested to enter plan mode')) return true;
@@ -15,7 +96,14 @@ export const normalizeUserDisplayParts = (parts: Part[]): Part[] => {
if (!synthetic) return true; if (!synthetic) return true;
if (part.type !== 'text') return false; if (part.type !== 'text') return false;
const text = (part as { text?: unknown }).text; const text = (part as { text?: unknown }).text;
return typeof text === 'string' ? shouldKeepSyntheticUserText(text) : false; if (typeof text !== 'string') {
return false;
}
const normalizedText = text.trimStart();
return shouldKeepSyntheticUserText(text)
|| normalizedText.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX)
|| normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX);
}) })
.map((part) => { .map((part) => {
const rawPart = part as Record<string, unknown>; const rawPart = part as Record<string, unknown>;
@@ -24,6 +112,15 @@ export const normalizeUserDisplayParts = (parts: Part[]): Part[] => {
} }
if (rawPart.type === 'text') { if (rawPart.type === 'text') {
const text = typeof rawPart.text === 'string' ? rawPart.text.trim() : ''; const text = typeof rawPart.text === 'string' ? rawPart.text.trim() : '';
const synthetic = rawPart.synthetic === true;
if (synthetic) {
const attachmentPart = buildGitHubAttachmentPart(text);
if (attachmentPart) {
return attachmentPart;
}
}
if (text.startsWith('The following tool was executed by the user')) { if (text.startsWith('The following tool was executed by the user')) {
return { type: 'text', text: '/shell' } as Part; return { type: 'text', text: '/shell' } as Part;
} }
@@ -25,6 +25,7 @@ import { cn } from '@/lib/utils';
interface ProjectNotesTodoPanelProps { interface ProjectNotesTodoPanelProps {
projectRef: ProjectRef | null; projectRef: ProjectRef | null;
projectLabel?: string | null;
canCreateWorktree?: boolean; canCreateWorktree?: boolean;
onActionComplete?: () => void; onActionComplete?: () => void;
className?: string; className?: string;
@@ -39,6 +40,7 @@ const createTodoId = (): string => {
export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
projectRef, projectRef,
projectLabel,
canCreateWorktree = false, canCreateWorktree = false,
onActionComplete, onActionComplete,
className, className,
@@ -48,6 +50,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
const [todos, setTodos] = React.useState<OpenChamberProjectTodoItem[]>([]); const [todos, setTodos] = React.useState<OpenChamberProjectTodoItem[]>([]);
const [newTodoText, setNewTodoText] = React.useState(''); const [newTodoText, setNewTodoText] = React.useState('');
const [sendingTodoId, setSendingTodoId] = React.useState<string | null>(null); const [sendingTodoId, setSendingTodoId] = React.useState<string | null>(null);
const [expandedTodoIds, setExpandedTodoIds] = React.useState<Set<string>>(() => new Set());
const currentSessionId = useSessionStore((state) => state.currentSessionId); const currentSessionId = useSessionStore((state) => state.currentSessionId);
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
@@ -77,6 +80,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
setNotes(''); setNotes('');
setTodos([]); setTodos([]);
setNewTodoText(''); setNewTodoText('');
setExpandedTodoIds(new Set());
return; return;
} }
@@ -92,6 +96,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
setNotes(data.notes); setNotes(data.notes);
setTodos(data.todos); setTodos(data.todos);
setNewTodoText(''); setNewTodoText('');
setExpandedTodoIds(new Set());
} catch { } catch {
if (!cancelled) { if (!cancelled) {
toast.error('Failed to load project notes'); toast.error('Failed to load project notes');
@@ -134,6 +139,18 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
void persistProjectData(notes, nextTodos); void persistProjectData(notes, nextTodos);
}, [newTodoText, notes, persistProjectData, todos]); }, [newTodoText, notes, persistProjectData, todos]);
const handleToggleTodoExpanded = React.useCallback((id: string) => {
setExpandedTodoIds((previous) => {
const next = new Set(previous);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const handleToggleTodo = React.useCallback( const handleToggleTodo = React.useCallback(
(id: string, completed: boolean) => { (id: string, completed: boolean) => {
const nextTodos = todos.map((todo) => (todo.id === id ? { ...todo, completed } : todo)); const nextTodos = todos.map((todo) => (todo.id === id ? { ...todo, completed } : todo));
@@ -241,7 +258,9 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
<div className={cn('w-full min-w-0 space-y-3 p-3', className)}> <div className={cn('w-full min-w-0 space-y-3 p-3', className)}>
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h3 className="typography-ui-label font-semibold text-foreground">Quick notes</h3> <h3 className="min-w-0 truncate typography-ui-label font-semibold text-foreground" title={projectRef.path}>
Quick notes - {projectLabel?.trim() || projectRef.path.split('/').filter(Boolean).pop() || projectRef.path}
</h3>
<span className="typography-meta text-muted-foreground">{notes.length}/{OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH}</span> <span className="typography-meta text-muted-foreground">{notes.length}/{OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH}</span>
</div> </div>
<Textarea <Textarea
@@ -249,15 +268,17 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
onChange={(event) => setNotes(event.target.value.slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH))} onChange={(event) => setNotes(event.target.value.slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH))}
onBlur={handleNotesBlur} onBlur={handleNotesBlur}
placeholder="Capture context, reminders, or links" placeholder="Capture context, reminders, or links"
className="min-h-24 resize-none" className="min-h-28 max-h-80 resize-none"
useScrollShadow
scrollShadowSize={56}
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h3 className="typography-ui-label font-semibold text-foreground">Todo</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="typography-ui-label font-semibold text-foreground">Todo</h3>
<span className="typography-meta text-muted-foreground">{todos.length} item{todos.length === 1 ? '' : 's'}</span> <span className="typography-meta text-muted-foreground">{todos.length} item{todos.length === 1 ? '' : 's'}</span>
<button <button
type="button" type="button"
@@ -268,6 +289,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
Clear completed Clear completed
</button> </button>
</div> </div>
<span className="typography-meta text-muted-foreground">{todoInputValue.length}/{OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -300,58 +322,69 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
<p className="px-3 py-3 typography-meta text-muted-foreground">No todos yet. Add a small checklist for this project.</p> <p className="px-3 py-3 typography-meta text-muted-foreground">No todos yet. Add a small checklist for this project.</p>
) : ( ) : (
<ul className="divide-y divide-border/50"> <ul className="divide-y divide-border/50">
{todos.map((todo) => ( {todos.map((todo) => {
<li key={todo.id} className="flex items-center gap-1.5 px-2.5 py-1.5"> const isExpandedTodo = expandedTodoIds.has(todo.id);
return (
<li key={todo.id} className="flex items-start gap-1.5 px-2.5 py-1.5">
<Checkbox <Checkbox
checked={todo.completed} checked={todo.completed}
onChange={(checked) => handleToggleTodo(todo.id, checked)} onChange={(checked) => handleToggleTodo(todo.id, checked)}
ariaLabel={`Mark "${todo.text}" complete`} ariaLabel={`Mark "${todo.text}" complete`}
className="mt-[3px] self-start"
/> />
<span
className={cn(
'min-w-0 flex-1 typography-ui-label text-foreground',
todo.completed && 'text-muted-foreground line-through'
)}
title={todo.text}
>
{todo.text}
</span>
<button <button
type="button" type="button"
onClick={() => handleDeleteTodo(todo.id)} onClick={() => handleToggleTodoExpanded(todo.id)}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" className={cn(
aria-label={`Delete "${todo.text}"`} 'mt-[3px] min-w-0 flex-1 self-start bg-transparent p-0 text-left typography-ui-label text-foreground',
isExpandedTodo ? 'whitespace-normal break-words' : 'truncate',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
todo.completed && 'text-muted-foreground line-through'
)}
title={isExpandedTodo ? undefined : todo.text}
aria-label={isExpandedTodo ? `Collapse todo "${todo.text}"` : `Expand todo "${todo.text}"`}
> >
<RiDeleteBinLine className="h-3.5 w-3.5" /> {todo.text}
</button> </button>
<DropdownMenu> <div className="mt-0.5 flex self-start items-center gap-0.5">
<DropdownMenuTrigger asChild> <button
<button type="button"
type="button" onClick={() => handleDeleteTodo(todo.id)}
disabled={sendingTodoId === todo.id} className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50" aria-label={`Delete "${todo.text}"`}
aria-label={`Send "${todo.text}"`} >
> <RiDeleteBinLine className="h-3.5 w-3.5" />
<RiSendPlaneLine className="h-3.5 w-3.5" /> </button>
</button> <DropdownMenu>
</DropdownMenuTrigger> <DropdownMenuTrigger asChild>
<DropdownMenuContent align="end" className="w-56"> <button
<DropdownMenuItem onClick={() => handleSendToCurrentSession(todo.text)}> type="button"
Send to current session disabled={sendingTodoId === todo.id}
</DropdownMenuItem> className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
<DropdownMenuItem onClick={() => handleSendToNewSession(todo.text)}> aria-label={`Send "${todo.text}"`}
Send to new session >
</DropdownMenuItem> <RiSendPlaneLine className="h-3.5 w-3.5" />
<DropdownMenuItem </button>
onClick={() => void handleSendToNewWorktreeSession(todo.id, todo.text)} </DropdownMenuTrigger>
disabled={!canCreateWorktree} <DropdownMenuContent align="end" className="w-56">
> <DropdownMenuItem onClick={() => handleSendToCurrentSession(todo.text)}>
Send to new worktree session Send to current session
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> <DropdownMenuItem onClick={() => handleSendToNewSession(todo.text)}>
</DropdownMenu> Send to new session
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleSendToNewWorktreeSession(todo.id, todo.text)}
disabled={!canCreateWorktree}
>
Send to new worktree session
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li> </li>
))} );
})}
</ul> </ul>
)} )}
</div> </div>
@@ -900,6 +900,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
: null), : null),
[activeProjectForHeader], [activeProjectForHeader],
); );
const activeProjectLabelForHeader = React.useMemo(
() => (activeProjectForHeader
? activeProjectForHeader.label?.trim()
|| formatDirectoryName(activeProjectForHeader.normalizedPath, homeDirectory)
|| activeProjectForHeader.normalizedPath
: null),
[activeProjectForHeader, homeDirectory],
);
const activeProjectIsRepo = React.useMemo( const activeProjectIsRepo = React.useMemo(
() => (activeProjectForHeader ? Boolean(projectRepoStatus.get(activeProjectForHeader.id)) : false), () => (activeProjectForHeader ? Boolean(projectRepoStatus.get(activeProjectForHeader.id)) : false),
@@ -1323,6 +1331,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
hideDirectoryControls={hideDirectoryControls} hideDirectoryControls={hideDirectoryControls}
handleOpenDirectoryDialog={handleOpenDirectoryDialog} handleOpenDirectoryDialog={handleOpenDirectoryDialog}
handleNewSession={handleSidebarNewSession} handleNewSession={handleSidebarNewSession}
useMobileNotesPanel={useMobileNotesPanel}
projectNotesPanelOpen={projectNotesPanelOpen}
setProjectNotesPanelOpen={setProjectNotesPanelOpen}
activeProjectRefForHeader={activeProjectRefForHeader}
activeProjectLabelForHeader={activeProjectLabelForHeader}
stableActiveProjectIsRepo={stableActiveProjectIsRepo}
headerActionIconClass={headerActionIconClass} headerActionIconClass={headerActionIconClass}
reserveHeaderActionsSpace={reserveHeaderActionsSpace} reserveHeaderActionsSpace={reserveHeaderActionsSpace}
headerActionButtonClass={headerActionButtonClass} headerActionButtonClass={headerActionButtonClass}
@@ -1379,6 +1393,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
onOpenShortcuts={toggleHelpDialog} onOpenShortcuts={toggleHelpDialog}
onOpenAbout={() => setAboutDialogOpen(true)} onOpenAbout={() => setAboutDialogOpen(true)}
onOpenUpdate={handleOpenUpdateDialog} onOpenUpdate={handleOpenUpdateDialog}
showRuntimeButtons={!isVSCode}
showUpdateButton={showSidebarUpdateButton} showUpdateButton={showSidebarUpdateButton}
/> />
@@ -1433,10 +1448,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<MobileOverlayPanel <MobileOverlayPanel
open={projectNotesPanelOpen} open={projectNotesPanelOpen}
onClose={() => setProjectNotesPanelOpen(false)} onClose={() => setProjectNotesPanelOpen(false)}
title="Project notes" title={activeProjectLabelForHeader ? `Project notes - ${activeProjectLabelForHeader}` : 'Project notes'}
> >
<ProjectNotesTodoPanel <ProjectNotesTodoPanel
projectRef={activeProjectRefForHeader} projectRef={activeProjectRefForHeader}
projectLabel={activeProjectLabelForHeader}
canCreateWorktree={stableActiveProjectIsRepo} canCreateWorktree={stableActiveProjectIsRepo}
onActionComplete={() => setProjectNotesPanelOpen(false)} onActionComplete={() => setProjectNotesPanelOpen(false)}
className="p-0" className="p-0"
@@ -8,6 +8,7 @@ type Props = {
onOpenShortcuts: () => void; onOpenShortcuts: () => void;
onOpenAbout: () => void; onOpenAbout: () => void;
onOpenUpdate: () => void; onOpenUpdate: () => void;
showRuntimeButtons?: boolean;
showUpdateButton?: boolean; showUpdateButton?: boolean;
}; };
@@ -18,34 +19,39 @@ export function SidebarFooter({
onOpenShortcuts, onOpenShortcuts,
onOpenAbout, onOpenAbout,
onOpenUpdate, onOpenUpdate,
showRuntimeButtons = true,
showUpdateButton = true, showUpdateButton = true,
}: Props): React.ReactNode { }: Props): React.ReactNode {
return ( return (
<div className="flex shrink-0 items-center justify-start gap-1 px-2.5 py-2"> <div className="flex shrink-0 items-center justify-start gap-1 px-2.5 py-2">
<Tooltip> {showRuntimeButtons ? (
<TooltipTrigger asChild> <>
<button type="button" onClick={onOpenSettings} className={footerButtonClassName} aria-label="Settings"> <Tooltip>
<RiSettings3Line className="h-4.5 w-4.5" /> <TooltipTrigger asChild>
</button> <button type="button" onClick={onOpenSettings} className={footerButtonClassName} aria-label="Settings">
</TooltipTrigger> <RiSettings3Line className="h-4.5 w-4.5" />
<TooltipContent side="top" sideOffset={4}><p>Settings</p></TooltipContent> </button>
</Tooltip> </TooltipTrigger>
<Tooltip> <TooltipContent side="top" sideOffset={4}><p>Settings</p></TooltipContent>
<TooltipTrigger asChild> </Tooltip>
<button type="button" onClick={onOpenShortcuts} className={footerButtonClassName} aria-label="Shortcuts"> <Tooltip>
<RiQuestionLine className="h-4.5 w-4.5" /> <TooltipTrigger asChild>
</button> <button type="button" onClick={onOpenShortcuts} className={footerButtonClassName} aria-label="Shortcuts">
</TooltipTrigger> <RiQuestionLine className="h-4.5 w-4.5" />
<TooltipContent side="top" sideOffset={4}><p>Shortcuts</p></TooltipContent> </button>
</Tooltip> </TooltipTrigger>
<Tooltip> <TooltipContent side="top" sideOffset={4}><p>Shortcuts</p></TooltipContent>
<TooltipTrigger asChild> </Tooltip>
<button type="button" onClick={onOpenAbout} className={footerButtonClassName} aria-label="About OpenChamber"> <Tooltip>
<RiInformationLine className="h-4.5 w-4.5" /> <TooltipTrigger asChild>
</button> <button type="button" onClick={onOpenAbout} className={footerButtonClassName} aria-label="About OpenChamber">
</TooltipTrigger> <RiInformationLine className="h-4.5 w-4.5" />
<TooltipContent side="top" sideOffset={4}><p>About OpenChamber</p></TooltipContent> </button>
</Tooltip> </TooltipTrigger>
<TooltipContent side="top" sideOffset={4}><p>About OpenChamber</p></TooltipContent>
</Tooltip>
</>
) : null}
{showUpdateButton ? ( {showUpdateButton ? (
<Button <Button
type="button" type="button"
@@ -16,13 +16,22 @@ import {
RiCloseLine, RiCloseLine,
RiContractUpDownLine, RiContractUpDownLine,
RiExpandUpDownLine, RiExpandUpDownLine,
RiStickyNoteLine,
} from '@remixicon/react'; } from '@remixicon/react';
import type { ProjectRef } from '@/lib/openchamberConfig';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { ProjectNotesTodoPanel } from '../ProjectNotesTodoPanel';
type Props = { type Props = {
hideDirectoryControls: boolean; hideDirectoryControls: boolean;
handleOpenDirectoryDialog: () => void; handleOpenDirectoryDialog: () => void;
handleNewSession: () => void; handleNewSession: () => void;
useMobileNotesPanel: boolean;
projectNotesPanelOpen: boolean;
setProjectNotesPanelOpen: (open: boolean) => void;
activeProjectRefForHeader: ProjectRef | null;
activeProjectLabelForHeader: string | null;
stableActiveProjectIsRepo: boolean;
headerActionIconClass: string; headerActionIconClass: string;
reserveHeaderActionsSpace: boolean; reserveHeaderActionsSpace: boolean;
headerActionButtonClass: string; headerActionButtonClass: string;
@@ -42,6 +51,12 @@ export function SidebarHeader(props: Props): React.ReactNode {
hideDirectoryControls, hideDirectoryControls,
handleOpenDirectoryDialog, handleOpenDirectoryDialog,
handleNewSession, handleNewSession,
useMobileNotesPanel,
projectNotesPanelOpen,
setProjectNotesPanelOpen,
activeProjectRefForHeader,
activeProjectLabelForHeader,
stableActiveProjectIsRepo,
headerActionIconClass, headerActionIconClass,
reserveHeaderActionsSpace, reserveHeaderActionsSpace,
headerActionButtonClass, headerActionButtonClass,
@@ -98,6 +113,49 @@ export function SidebarHeader(props: Props): React.ReactNode {
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{useMobileNotesPanel ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setProjectNotesPanelOpen(true)}
className={headerActionButtonClass}
aria-label="Project notes"
disabled={!activeProjectRefForHeader}
>
<RiStickyNoteLine className={headerActionIconClass} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Project notes</p></TooltipContent>
</Tooltip>
) : (
<DropdownMenu open={projectNotesPanelOpen} onOpenChange={setProjectNotesPanelOpen} modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className={headerActionButtonClass}
aria-label="Project notes"
disabled={!activeProjectRefForHeader}
>
<RiStickyNoteLine className={headerActionIconClass} />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>Project notes</p></TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-[420px] max-w-[min(92vw,420px)] p-0">
<ProjectNotesTodoPanel
projectRef={activeProjectRefForHeader}
projectLabel={activeProjectLabelForHeader}
canCreateWorktree={stableActiveProjectIsRepo}
onActionComplete={() => setProjectNotesPanelOpen(false)}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@@ -217,7 +217,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
<div className={cn( <div className={cn(
'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1', 'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1',
isRepo && !hideDirectoryControls ? 'right-7' : 'right-0.5', showCreateButtons ? 'right-7' : 'right-0.5',
)}> )}>
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? ( {showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? (
<Tooltip> <Tooltip>
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
export type ScrollShadowProps = React.HTMLAttributes<HTMLDivElement> & { export type ScrollShadowProps = React.HTMLAttributes<HTMLElement> & {
as?: React.ElementType;
orientation?: "vertical" | "horizontal"; orientation?: "vertical" | "horizontal";
offset?: number; offset?: number;
size?: number; size?: number;
@@ -23,9 +24,10 @@ function mergeRefs<T>(...refs: Array<React.Ref<T>>): React.RefCallback<T> {
}; };
} }
export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>( export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
( (
{ {
as: Component = "div",
orientation = "vertical", orientation = "vertical",
offset = 0, offset = 0,
size = 48, size = 48,
@@ -41,7 +43,7 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
}, },
ref, ref,
) => { ) => {
const internalRef = React.useRef<HTMLDivElement>(null); const internalRef = React.useRef<HTMLElement>(null);
const visibleRef = React.useRef<"both" | "none" | "top" | "bottom" | "left" | "right">("none"); const visibleRef = React.useRef<"both" | "none" | "top" | "bottom" | "left" | "right">("none");
const dataScrollShadow = (rest as Record<string, unknown>)["data-scroll-shadow"]; const dataScrollShadow = (rest as Record<string, unknown>)["data-scroll-shadow"];
@@ -147,7 +149,7 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
}, [checkOverflow, observeMutations]); }, [checkOverflow, observeMutations]);
return ( return (
<div <Component
{...rest} {...rest}
ref={mergeRefs(internalRef, ref)} ref={mergeRefs(internalRef, ref)}
className={className} className={className}
@@ -156,7 +158,7 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
style={mergedStyle} style={mergedStyle}
> >
{children} {children}
</div> </Component>
); );
}, },
); );
@@ -53,7 +53,8 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
> >
{useScrollShadow ? ( {useScrollShadow ? (
<ScrollShadow <ScrollShadow
ref={containerRef as React.Ref<HTMLDivElement>} as={Component}
ref={containerRef as React.Ref<HTMLElement>}
size={scrollShadowSize} size={scrollShadowSize}
className={cn( className={cn(
"overlay-scrollbar-target overlay-scrollbar-container", "overlay-scrollbar-target overlay-scrollbar-container",
+13 -5
View File
@@ -7,23 +7,31 @@ type TextareaProps = React.ComponentProps<"textarea"> & {
outerClassName?: string; outerClassName?: string;
scrollbarClassName?: string; scrollbarClassName?: string;
fillContainer?: boolean; fillContainer?: boolean;
useScrollShadow?: boolean;
scrollShadowSize?: number;
}; };
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, outerClassName, scrollbarClassName, fillContainer = false, ...props }, ref) => { ({ className, outerClassName, scrollbarClassName, fillContainer = false, useScrollShadow = false, scrollShadowSize, ...props }, ref) => {
return ( return (
<ScrollableOverlay <ScrollableOverlay
as="textarea" as="textarea"
ref={ref as React.Ref<HTMLTextAreaElement>} ref={ref as React.Ref<HTMLTextAreaElement>}
disableHorizontal disableHorizontal
fillContainer={fillContainer} fillContainer={fillContainer}
outerClassName={cn("w-full rounded-lg focus-within:ring-1 focus-within:ring-primary/50", outerClassName)} useScrollShadow={useScrollShadow}
scrollShadowSize={scrollShadowSize}
outerClassName={cn(
"w-full rounded-lg focus-within:ring-1 focus-within:ring-inset focus-within:ring-primary/50",
useScrollShadow && "border border-border/80 hover:border-input",
outerClassName
)}
scrollbarClassName={scrollbarClassName} scrollbarClassName={scrollbarClassName}
className={cn( className={cn(
"text-foreground border border-border/80 placeholder:text-muted-foreground appearance-none dark:bg-input/30 flex min-h-16 w-full rounded-lg bg-transparent px-3 py-2 typography-markdown outline-none focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:typography-ui-label", "text-foreground placeholder:text-muted-foreground appearance-none dark:bg-input/30 flex min-h-16 w-full rounded-lg bg-transparent px-3 py-2 typography-markdown outline-none focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:typography-ui-label",
useScrollShadow ? "border-0" : "border border-border/80 hover:border-input focus:border-primary/70",
fillContainer ? "[field-sizing:fixed]" : "field-sizing-content", fillContainer ? "[field-sizing:fixed]" : "field-sizing-content",
"hover:border-input aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"focus:border-primary/70",
className className
)} )}
spellCheck={false} spellCheck={false}
@@ -937,8 +937,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const diffViewMode = useUIStore((state) => state.diffViewMode); const diffViewMode = useUIStore((state) => state.diffViewMode);
const setDiffViewMode = useUIStore((state) => state.setDiffViewMode); const setDiffViewMode = useUIStore((state) => state.setDiffViewMode);
const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine); const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine);
// Default to wrap on mobile const diffWrapLines = diffWrapLinesStore;
const diffWrapLines = isMobile || diffWrapLinesStore;
const isStackedView = diffViewMode === 'stacked'; const isStackedView = diffViewMode === 'stacked';
const isMobileLayout = isMobile || screenWidth <= 768; const isMobileLayout = isMobile || screenWidth <= 768;
+18 -1
View File
@@ -1,5 +1,8 @@
import type { Part } from "@opencode-ai/sdk/v2"; import type { Part } from "@opencode-ai/sdk/v2";
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
export const isSyntheticPart = (part: Part | undefined): boolean => { export const isSyntheticPart = (part: Part | undefined): boolean => {
if (!part || typeof part !== "object") { if (!part || typeof part !== "object") {
return false; return false;
@@ -31,6 +34,20 @@ export const filterSyntheticParts = (parts: Part[] | undefined): Part[] => {
const hasNonSynthetic = parts.some((part) => !isSyntheticPart(part)); const hasNonSynthetic = parts.some((part) => !isSyntheticPart(part));
const shouldKeepSyntheticPart = (part: Part): boolean => {
if (!isSyntheticPart(part) || part.type !== 'text') {
return false;
}
const text = (part as { text?: unknown }).text;
if (typeof text !== 'string') {
return false;
}
const trimmed = text.trimStart();
return trimmed.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX) || trimmed.startsWith(GITHUB_PR_CONTEXT_PREFIX);
};
// If there are non-synthetic parts, filter out synthetic ones // If there are non-synthetic parts, filter out synthetic ones
if (hasNonSynthetic) { if (hasNonSynthetic) {
// Optimization: Check if there are actually any synthetic parts to filter. // Optimization: Check if there are actually any synthetic parts to filter.
@@ -39,7 +56,7 @@ export const filterSyntheticParts = (parts: Part[] | undefined): Part[] => {
if (!hasSynthetic) { if (!hasSynthetic) {
return parts; return parts;
} }
return parts.filter((part) => !isSyntheticPart(part)); return parts.filter((part) => !isSyntheticPart(part) || shouldKeepSyntheticPart(part));
} }
// If all parts are synthetic, return them all (so message is displayed) // If all parts are synthetic, return them all (so message is displayed)
+2 -2
View File
@@ -258,7 +258,7 @@ class OpencodeService {
// Set the current working directory for all API calls // Set the current working directory for all API calls
setDirectory(directory: string | undefined) { setDirectory(directory: string | undefined) {
this.currentDirectory = directory; this.currentDirectory = this.normalizeCandidatePath(directory) ?? directory;
} }
getDirectory(): string | undefined { getDirectory(): string | undefined {
@@ -272,7 +272,7 @@ class OpencodeService {
} }
const previousDirectory = this.currentDirectory; const previousDirectory = this.currentDirectory;
this.currentDirectory = directory; this.currentDirectory = this.normalizeCandidatePath(directory) ?? directory;
try { try {
return await fn(); return await fn();
} finally { } finally {
+4 -1
View File
@@ -275,7 +275,10 @@ const normalizePath = (value?: string | null): string | null => {
if (!trimmed) { if (!trimmed) {
return null; return null;
} }
const replaced = trimmed.replace(/\\/g, "/"); const replaced = trimmed
.replace(/\\/g, "/")
.replace(/^([a-z]):\//, (_, letter: string) => `${letter.toUpperCase()}:/`)
.replace(/^\/([a-z]):\//, (_, letter: string) => `/${letter.toUpperCase()}:/`);
if (replaced === "/") { if (replaced === "/") {
return "/"; return "/";
} }
+157
View File
@@ -297,6 +297,161 @@ function getCandidateBaseUrls(serverUrl: string): string[] {
} }
} }
let cachedLoginShellEnvSnapshot: Record<string, string> | null | undefined;
function parseNullSeparatedEnvSnapshot(raw: string): Record<string, string> | null {
if (typeof raw !== 'string' || raw.length === 0) {
return null;
}
const result: Record<string, string> = {};
const entries = raw.split('\0');
for (const entry of entries) {
if (!entry) {
continue;
}
const idx = entry.indexOf('=');
if (idx <= 0) {
continue;
}
const key = entry.slice(0, idx);
const value = entry.slice(idx + 1);
result[key] = value;
}
return Object.keys(result).length > 0 ? result : null;
}
function getWindowsShellEnvSnapshot(): Record<string, string> | null {
const parseResult = (stdout: string | null | undefined) => parseNullSeparatedEnvSnapshot(typeof stdout === 'string' ? stdout : '');
const psScript =
"Get-ChildItem Env: | ForEach-Object { [Console]::Out.Write($_.Name); [Console]::Out.Write('='); [Console]::Out.Write($_.Value); [Console]::Out.Write([char]0) }";
const powershellCandidates = [
'pwsh.exe',
'powershell.exe',
path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
];
for (const shellPath of powershellCandidates) {
try {
const result = spawnSync(shellPath, ['-NoLogo', '-Command', psScript], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 10 * 1024 * 1024,
windowsHide: true,
});
if (result.status !== 0) {
continue;
}
const parsed = parseResult(result.stdout);
if (parsed) {
return parsed;
}
} catch {
continue;
}
}
const comspec = process.env.ComSpec || 'cmd.exe';
try {
const result = spawnSync(comspec, ['/d', '/s', '/c', 'set'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 10 * 1024 * 1024,
windowsHide: true,
});
if (result.status === 0 && typeof result.stdout === 'string' && result.stdout.length > 0) {
return parseNullSeparatedEnvSnapshot(result.stdout.replace(/\r?\n/g, '\0'));
}
} catch {
return null;
}
return null;
}
function getLoginShellEnvSnapshot(): Record<string, string> | null {
if (cachedLoginShellEnvSnapshot !== undefined) {
return cachedLoginShellEnvSnapshot;
}
if (process.platform === 'win32') {
const windowsSnapshot = getWindowsShellEnvSnapshot();
cachedLoginShellEnvSnapshot = windowsSnapshot;
return windowsSnapshot;
}
const shellCandidates = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean) as string[];
for (const shellPath of shellCandidates) {
if (!isExecutable(shellPath)) {
continue;
}
try {
const result = spawnSync(shellPath, ['-lic', 'env -0'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 10 * 1024 * 1024,
windowsHide: true,
});
if (result.status !== 0) {
continue;
}
const parsed = parseNullSeparatedEnvSnapshot(result.stdout || '');
if (parsed) {
cachedLoginShellEnvSnapshot = parsed;
return parsed;
}
} catch {
continue;
}
}
cachedLoginShellEnvSnapshot = null;
return null;
}
function mergePathValues(preferred: string, fallback: string): string {
const merged = new Set<string>();
const addSegments = (value: string) => {
if (typeof value !== 'string' || !value) {
return;
}
for (const segment of value.split(path.delimiter)) {
if (segment) {
merged.add(segment);
}
}
};
addSegments(preferred);
addSegments(fallback);
return Array.from(merged).join(path.delimiter);
}
function applyLoginShellEnvSnapshot() {
const snapshot = getLoginShellEnvSnapshot();
if (!snapshot) {
return;
}
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']);
for (const [key, value] of Object.entries(snapshot)) {
if (skipKeys.has(key)) {
continue;
}
const existing = process.env[key];
if (typeof existing === 'string' && existing.length > 0) {
continue;
}
process.env[key] = value;
}
process.env.PATH = mergePathValues(snapshot.PATH || '', process.env.PATH || '');
}
async function waitForReady( async function waitForReady(
serverUrl: string, serverUrl: string,
timeoutMs = 15000, timeoutMs = 15000,
@@ -611,6 +766,8 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
managedApiUrlOverride = null; managedApiUrlOverride = null;
try { try {
applyLoginShellEnvSnapshot();
// Best-effort: locate CLI even when VS Code PATH is stale. // Best-effort: locate CLI even when VS Code PATH is stale.
const resolvedCli = resolveOpencodeCliPath(); const resolvedCli = resolveOpencodeCliPath();
if (resolvedCli) { if (resolvedCli) {
-3
View File
@@ -695,9 +695,6 @@ const getJsonWriteTarget = (
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) { if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
return { config: projectConfig, path: paths.projectPath }; return { config: projectConfig, path: paths.projectPath };
} }
if (paths.projectPath) {
return { config: projectConfig, path: paths.projectPath };
}
return { config: userConfig, path: paths.userPath }; return { config: userConfig, path: paths.userPath };
}; };
+4 -1
View File
@@ -251,7 +251,10 @@ onThemeChange((payload) => {
const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder; const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder;
if (workspaceFolder) { if (workspaceFolder) {
const normalizeWorkspacePath = (value: string) => { const normalizeWorkspacePath = (value: string) => {
const normalized = value.replace(/\\/g, '/'); const normalized = value
.replace(/\\/g, '/')
.replace(/^([a-z]):\//, (_, letter: string) => `${letter.toUpperCase()}:/`)
.replace(/^\/([a-z]):\//, (_, letter: string) => `/${letter.toUpperCase()}:/`);
if (normalized === '/') { if (normalized === '/') {
return '/'; return '/';
} }
+10 -2
View File
@@ -237,10 +237,18 @@ const createGit = async (directory) => {
const env = await buildGitEnv(); const env = await buildGitEnv();
const spawnOptions = { windowsHide: true }; const spawnOptions = { windowsHide: true };
const binary = getGitBinary(); const binary = getGitBinary();
const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe';
const unsafe = hasCustomBinary ? { allowUnsafeCustomBinary: true } : undefined;
if (!directory) { if (!directory) {
return simpleGit({ env, spawnOptions, binary }); return simpleGit({ env, spawnOptions, binary, unsafe });
} }
return simpleGit({ baseDir: normalizeDirectoryPath(directory), env, spawnOptions, binary }); return simpleGit({
baseDir: normalizeDirectoryPath(directory),
env,
spawnOptions,
binary,
unsafe,
});
}; };
const normalizeDirectoryPath = (value) => { const normalizeDirectoryPath = (value) => {
@@ -240,9 +240,6 @@ function getJsonWriteTarget(layers, preferredScope) {
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) { if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
return { config: projectConfig, path: paths.projectPath }; return { config: projectConfig, path: paths.projectPath };
} }
if (paths.projectPath) {
return { config: projectConfig, path: paths.projectPath };
}
return { config: userConfig, path: paths.userPath }; return { config: userConfig, path: paths.userPath };
} }