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:
committed by
GitHub
parent
b4949c6e33
commit
7356090e3d
@@ -37,6 +37,23 @@ const 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'
|
||||
? `openchamber-server-${targetTriple}.exe`
|
||||
: `openchamber-server-${targetTriple}`;
|
||||
@@ -96,13 +113,19 @@ await copyDir(webDistDir, resourcesWebDistDir);
|
||||
console.log('[desktop] building openchamber-server sidecar...');
|
||||
await fs.mkdir(sidecarsDir, { recursive: true });
|
||||
|
||||
run(bunExe, [
|
||||
const buildArgs = [
|
||||
'build',
|
||||
'--compile',
|
||||
path.join(webDir, 'server', 'index.js'),
|
||||
'--outfile',
|
||||
sidecarOutPath,
|
||||
], repoRoot);
|
||||
];
|
||||
|
||||
if (compileTarget) {
|
||||
buildArgs.push('--target', compileTarget);
|
||||
}
|
||||
|
||||
run(bunExe, buildArgs, repoRoot);
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.chmod(sidecarOutPath, 0o755);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { useUIStore } from '@/stores/useUIStore';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
@@ -313,6 +314,19 @@ interface FilePart {
|
||||
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 {
|
||||
files: FilePart[];
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
@@ -333,6 +347,14 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
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) => {
|
||||
if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -347,7 +369,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
() =>
|
||||
imageFiles.flatMap((file) => {
|
||||
if (!file.url) return [];
|
||||
const filename = extractFilename(file.filename) || 'Image';
|
||||
const filename = resolveDisplayName(file) || 'Image';
|
||||
return [{
|
||||
url: file.url,
|
||||
mimeType: file.mime,
|
||||
@@ -397,21 +419,41 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
{otherFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{otherFiles.map((file, index) => {
|
||||
const fileName = extractFilename(file.filename || file.url);
|
||||
const fileName = resolveDisplayName(file);
|
||||
const sizeText = formatFileSize(file.size);
|
||||
const githubLinkKind = getGitHubLinkKind(file);
|
||||
return (
|
||||
<Tooltip key={`file-${file.url || file.filename || index}`}>
|
||||
<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">
|
||||
{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>
|
||||
{githubLinkKind && file.url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void openExternalUrl(file.url || '');
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<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="flex snap-x snap-mandatory gap-2">
|
||||
{imageFiles.map((file, index) => {
|
||||
const filename = extractFilename(file.filename) || 'Image';
|
||||
const filename = resolveDisplayName(file) || 'Image';
|
||||
|
||||
return (
|
||||
<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"
|
||||
)}>
|
||||
{fileItems.map((file, index) => {
|
||||
const fileName = extractFilename(file.filename || file.url);
|
||||
const fileName = resolveDisplayName(file);
|
||||
const isImage = file.mime?.startsWith('image/');
|
||||
const sizeText = formatFileSize(file.size);
|
||||
const githubLinkKind = getGitHubLinkKind(file);
|
||||
|
||||
if (isImage && file.url) {
|
||||
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 (
|
||||
<Tooltip key={index}>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -1,5 +1,86 @@
|
||||
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 trimmed = text.trim();
|
||||
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 (part.type !== 'text') return false;
|
||||
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) => {
|
||||
const rawPart = part as Record<string, unknown>;
|
||||
@@ -24,6 +112,15 @@ export const normalizeUserDisplayParts = (parts: Part[]): Part[] => {
|
||||
}
|
||||
if (rawPart.type === 'text') {
|
||||
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')) {
|
||||
return { type: 'text', text: '/shell' } as Part;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProjectNotesTodoPanelProps {
|
||||
projectRef: ProjectRef | null;
|
||||
projectLabel?: string | null;
|
||||
canCreateWorktree?: boolean;
|
||||
onActionComplete?: () => void;
|
||||
className?: string;
|
||||
@@ -39,6 +40,7 @@ const createTodoId = (): string => {
|
||||
|
||||
export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
projectRef,
|
||||
projectLabel,
|
||||
canCreateWorktree = false,
|
||||
onActionComplete,
|
||||
className,
|
||||
@@ -48,6 +50,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
const [todos, setTodos] = React.useState<OpenChamberProjectTodoItem[]>([]);
|
||||
const [newTodoText, setNewTodoText] = React.useState('');
|
||||
const [sendingTodoId, setSendingTodoId] = React.useState<string | null>(null);
|
||||
const [expandedTodoIds, setExpandedTodoIds] = React.useState<Set<string>>(() => new Set());
|
||||
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
|
||||
@@ -77,6 +80,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
setNotes('');
|
||||
setTodos([]);
|
||||
setNewTodoText('');
|
||||
setExpandedTodoIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,6 +96,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
setNotes(data.notes);
|
||||
setTodos(data.todos);
|
||||
setNewTodoText('');
|
||||
setExpandedTodoIds(new Set());
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
toast.error('Failed to load project notes');
|
||||
@@ -134,6 +139,18 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
void persistProjectData(notes, nextTodos);
|
||||
}, [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(
|
||||
(id: string, completed: boolean) => {
|
||||
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="space-y-1">
|
||||
<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>
|
||||
</div>
|
||||
<Textarea
|
||||
@@ -249,15 +268,17 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
onChange={(event) => setNotes(event.target.value.slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH))}
|
||||
onBlur={handleNotesBlur}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-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">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
@@ -268,6 +289,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
Clear completed
|
||||
</button>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground">{todoInputValue.length}/{OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH}</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/50">
|
||||
{todos.map((todo) => (
|
||||
<li key={todo.id} className="flex items-center gap-1.5 px-2.5 py-1.5">
|
||||
{todos.map((todo) => {
|
||||
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
|
||||
checked={todo.completed}
|
||||
onChange={(checked) => handleToggleTodo(todo.id, checked)}
|
||||
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
|
||||
type="button"
|
||||
onClick={() => handleDeleteTodo(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"
|
||||
aria-label={`Delete "${todo.text}"`}
|
||||
onClick={() => handleToggleTodoExpanded(todo.id)}
|
||||
className={cn(
|
||||
'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>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingTodoId === 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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`Send "${todo.text}"`}
|
||||
>
|
||||
<RiSendPlaneLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={() => handleSendToCurrentSession(todo.text)}>
|
||||
Send to current session
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleSendToNewSession(todo.text)}>
|
||||
Send to new session
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void handleSendToNewWorktreeSession(todo.id, todo.text)}
|
||||
disabled={!canCreateWorktree}
|
||||
>
|
||||
Send to new worktree session
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="mt-0.5 flex self-start items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteTodo(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"
|
||||
aria-label={`Delete "${todo.text}"`}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`Send "${todo.text}"`}
|
||||
>
|
||||
<RiSendPlaneLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={() => handleSendToCurrentSession(todo.text)}>
|
||||
Send to current session
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleSendToNewSession(todo.text)}>
|
||||
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>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -900,6 +900,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
: null),
|
||||
[activeProjectForHeader],
|
||||
);
|
||||
const activeProjectLabelForHeader = React.useMemo(
|
||||
() => (activeProjectForHeader
|
||||
? activeProjectForHeader.label?.trim()
|
||||
|| formatDirectoryName(activeProjectForHeader.normalizedPath, homeDirectory)
|
||||
|| activeProjectForHeader.normalizedPath
|
||||
: null),
|
||||
[activeProjectForHeader, homeDirectory],
|
||||
);
|
||||
|
||||
const activeProjectIsRepo = React.useMemo(
|
||||
() => (activeProjectForHeader ? Boolean(projectRepoStatus.get(activeProjectForHeader.id)) : false),
|
||||
@@ -1323,6 +1331,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
hideDirectoryControls={hideDirectoryControls}
|
||||
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
|
||||
handleNewSession={handleSidebarNewSession}
|
||||
useMobileNotesPanel={useMobileNotesPanel}
|
||||
projectNotesPanelOpen={projectNotesPanelOpen}
|
||||
setProjectNotesPanelOpen={setProjectNotesPanelOpen}
|
||||
activeProjectRefForHeader={activeProjectRefForHeader}
|
||||
activeProjectLabelForHeader={activeProjectLabelForHeader}
|
||||
stableActiveProjectIsRepo={stableActiveProjectIsRepo}
|
||||
headerActionIconClass={headerActionIconClass}
|
||||
reserveHeaderActionsSpace={reserveHeaderActionsSpace}
|
||||
headerActionButtonClass={headerActionButtonClass}
|
||||
@@ -1379,6 +1393,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
onOpenShortcuts={toggleHelpDialog}
|
||||
onOpenAbout={() => setAboutDialogOpen(true)}
|
||||
onOpenUpdate={handleOpenUpdateDialog}
|
||||
showRuntimeButtons={!isVSCode}
|
||||
showUpdateButton={showSidebarUpdateButton}
|
||||
/>
|
||||
|
||||
@@ -1433,10 +1448,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
<MobileOverlayPanel
|
||||
open={projectNotesPanelOpen}
|
||||
onClose={() => setProjectNotesPanelOpen(false)}
|
||||
title="Project notes"
|
||||
title={activeProjectLabelForHeader ? `Project notes - ${activeProjectLabelForHeader}` : 'Project notes'}
|
||||
>
|
||||
<ProjectNotesTodoPanel
|
||||
projectRef={activeProjectRefForHeader}
|
||||
projectLabel={activeProjectLabelForHeader}
|
||||
canCreateWorktree={stableActiveProjectIsRepo}
|
||||
onActionComplete={() => setProjectNotesPanelOpen(false)}
|
||||
className="p-0"
|
||||
|
||||
@@ -8,6 +8,7 @@ type Props = {
|
||||
onOpenShortcuts: () => void;
|
||||
onOpenAbout: () => void;
|
||||
onOpenUpdate: () => void;
|
||||
showRuntimeButtons?: boolean;
|
||||
showUpdateButton?: boolean;
|
||||
};
|
||||
|
||||
@@ -18,34 +19,39 @@ export function SidebarFooter({
|
||||
onOpenShortcuts,
|
||||
onOpenAbout,
|
||||
onOpenUpdate,
|
||||
showRuntimeButtons = true,
|
||||
showUpdateButton = true,
|
||||
}: Props): React.ReactNode {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-start gap-1 px-2.5 py-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" onClick={onOpenSettings} className={footerButtonClassName} aria-label="Settings">
|
||||
<RiSettings3Line className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>Settings</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" onClick={onOpenShortcuts} className={footerButtonClassName} aria-label="Shortcuts">
|
||||
<RiQuestionLine className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>Shortcuts</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" onClick={onOpenAbout} className={footerButtonClassName} aria-label="About OpenChamber">
|
||||
<RiInformationLine className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>About OpenChamber</p></TooltipContent>
|
||||
</Tooltip>
|
||||
{showRuntimeButtons ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" onClick={onOpenSettings} className={footerButtonClassName} aria-label="Settings">
|
||||
<RiSettings3Line className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>Settings</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" onClick={onOpenShortcuts} className={footerButtonClassName} aria-label="Shortcuts">
|
||||
<RiQuestionLine className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>Shortcuts</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" onClick={onOpenAbout} className={footerButtonClassName} aria-label="About OpenChamber">
|
||||
<RiInformationLine className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>About OpenChamber</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
{showUpdateButton ? (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -16,13 +16,22 @@ import {
|
||||
RiCloseLine,
|
||||
RiContractUpDownLine,
|
||||
RiExpandUpDownLine,
|
||||
RiStickyNoteLine,
|
||||
} from '@remixicon/react';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { ProjectNotesTodoPanel } from '../ProjectNotesTodoPanel';
|
||||
|
||||
type Props = {
|
||||
hideDirectoryControls: boolean;
|
||||
handleOpenDirectoryDialog: () => void;
|
||||
handleNewSession: () => void;
|
||||
useMobileNotesPanel: boolean;
|
||||
projectNotesPanelOpen: boolean;
|
||||
setProjectNotesPanelOpen: (open: boolean) => void;
|
||||
activeProjectRefForHeader: ProjectRef | null;
|
||||
activeProjectLabelForHeader: string | null;
|
||||
stableActiveProjectIsRepo: boolean;
|
||||
headerActionIconClass: string;
|
||||
reserveHeaderActionsSpace: boolean;
|
||||
headerActionButtonClass: string;
|
||||
@@ -42,6 +51,12 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
hideDirectoryControls,
|
||||
handleOpenDirectoryDialog,
|
||||
handleNewSession,
|
||||
useMobileNotesPanel,
|
||||
projectNotesPanelOpen,
|
||||
setProjectNotesPanelOpen,
|
||||
activeProjectRefForHeader,
|
||||
activeProjectLabelForHeader,
|
||||
stableActiveProjectIsRepo,
|
||||
headerActionIconClass,
|
||||
reserveHeaderActionsSpace,
|
||||
headerActionButtonClass,
|
||||
@@ -98,6 +113,49 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -217,7 +217,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
|
||||
<div className={cn(
|
||||
'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 ? (
|
||||
<Tooltip>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
export type ScrollShadowProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
export type ScrollShadowProps = React.HTMLAttributes<HTMLElement> & {
|
||||
as?: React.ElementType;
|
||||
orientation?: "vertical" | "horizontal";
|
||||
offset?: 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",
|
||||
offset = 0,
|
||||
size = 48,
|
||||
@@ -41,7 +43,7 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
|
||||
},
|
||||
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 dataScrollShadow = (rest as Record<string, unknown>)["data-scroll-shadow"];
|
||||
@@ -147,7 +149,7 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
|
||||
}, [checkOverflow, observeMutations]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<Component
|
||||
{...rest}
|
||||
ref={mergeRefs(internalRef, ref)}
|
||||
className={className}
|
||||
@@ -156,7 +158,7 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
|
||||
style={mergedStyle}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -53,7 +53,8 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
||||
>
|
||||
{useScrollShadow ? (
|
||||
<ScrollShadow
|
||||
ref={containerRef as React.Ref<HTMLDivElement>}
|
||||
as={Component}
|
||||
ref={containerRef as React.Ref<HTMLElement>}
|
||||
size={scrollShadowSize}
|
||||
className={cn(
|
||||
"overlay-scrollbar-target overlay-scrollbar-container",
|
||||
|
||||
@@ -7,23 +7,31 @@ type TextareaProps = React.ComponentProps<"textarea"> & {
|
||||
outerClassName?: string;
|
||||
scrollbarClassName?: string;
|
||||
fillContainer?: boolean;
|
||||
useScrollShadow?: boolean;
|
||||
scrollShadowSize?: number;
|
||||
};
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, outerClassName, scrollbarClassName, fillContainer = false, ...props }, ref) => {
|
||||
({ className, outerClassName, scrollbarClassName, fillContainer = false, useScrollShadow = false, scrollShadowSize, ...props }, ref) => {
|
||||
return (
|
||||
<ScrollableOverlay
|
||||
as="textarea"
|
||||
ref={ref as React.Ref<HTMLTextAreaElement>}
|
||||
disableHorizontal
|
||||
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}
|
||||
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",
|
||||
"hover:border-input aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"focus:border-primary/70",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
spellCheck={false}
|
||||
|
||||
@@ -937,8 +937,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const diffViewMode = useUIStore((state) => state.diffViewMode);
|
||||
const setDiffViewMode = useUIStore((state) => state.setDiffViewMode);
|
||||
const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine);
|
||||
// Default to wrap on mobile
|
||||
const diffWrapLines = isMobile || diffWrapLinesStore;
|
||||
const diffWrapLines = diffWrapLinesStore;
|
||||
|
||||
const isStackedView = diffViewMode === 'stacked';
|
||||
const isMobileLayout = isMobile || screenWidth <= 768;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 => {
|
||||
if (!part || typeof part !== "object") {
|
||||
return false;
|
||||
@@ -31,6 +34,20 @@ export const filterSyntheticParts = (parts: Part[] | undefined): 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 (hasNonSynthetic) {
|
||||
// Optimization: Check if there are actually any synthetic parts to filter.
|
||||
@@ -39,7 +56,7 @@ export const filterSyntheticParts = (parts: Part[] | undefined): Part[] => {
|
||||
if (!hasSynthetic) {
|
||||
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)
|
||||
|
||||
@@ -258,7 +258,7 @@ class OpencodeService {
|
||||
|
||||
// Set the current working directory for all API calls
|
||||
setDirectory(directory: string | undefined) {
|
||||
this.currentDirectory = directory;
|
||||
this.currentDirectory = this.normalizeCandidatePath(directory) ?? directory;
|
||||
}
|
||||
|
||||
getDirectory(): string | undefined {
|
||||
@@ -272,7 +272,7 @@ class OpencodeService {
|
||||
}
|
||||
|
||||
const previousDirectory = this.currentDirectory;
|
||||
this.currentDirectory = directory;
|
||||
this.currentDirectory = this.normalizeCandidatePath(directory) ?? directory;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
|
||||
@@ -275,7 +275,10 @@ const normalizePath = (value?: string | null): string | null => {
|
||||
if (!trimmed) {
|
||||
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 === "/") {
|
||||
return "/";
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
serverUrl: string,
|
||||
timeoutMs = 15000,
|
||||
@@ -611,6 +766,8 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
managedApiUrlOverride = null;
|
||||
|
||||
try {
|
||||
applyLoginShellEnvSnapshot();
|
||||
|
||||
// Best-effort: locate CLI even when VS Code PATH is stale.
|
||||
const resolvedCli = resolveOpencodeCliPath();
|
||||
if (resolvedCli) {
|
||||
|
||||
@@ -695,9 +695,6 @@ const getJsonWriteTarget = (
|
||||
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
|
||||
return { config: projectConfig, path: paths.projectPath };
|
||||
}
|
||||
if (paths.projectPath) {
|
||||
return { config: projectConfig, path: paths.projectPath };
|
||||
}
|
||||
return { config: userConfig, path: paths.userPath };
|
||||
};
|
||||
|
||||
|
||||
@@ -251,7 +251,10 @@ onThemeChange((payload) => {
|
||||
const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder;
|
||||
if (workspaceFolder) {
|
||||
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 === '/') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
@@ -237,10 +237,18 @@ const createGit = async (directory) => {
|
||||
const env = await buildGitEnv();
|
||||
const spawnOptions = { windowsHide: true };
|
||||
const binary = getGitBinary();
|
||||
const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe';
|
||||
const unsafe = hasCustomBinary ? { allowUnsafeCustomBinary: true } : undefined;
|
||||
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) => {
|
||||
|
||||
@@ -240,9 +240,6 @@ function getJsonWriteTarget(layers, preferredScope) {
|
||||
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
|
||||
return { config: projectConfig, path: paths.projectPath };
|
||||
}
|
||||
if (paths.projectPath) {
|
||||
return { config: projectConfig, path: paths.projectPath };
|
||||
}
|
||||
return { config: userConfig, path: paths.userPath };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user