diff --git a/packages/desktop/scripts/build-sidecar.mjs b/packages/desktop/scripts/build-sidecar.mjs
index 21646650..11695fed 100644
--- a/packages/desktop/scripts/build-sidecar.mjs
+++ b/packages/desktop/scripts/build-sidecar.mjs
@@ -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);
diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx
index f9b8a4eb..8016a6ba 100644
--- a/packages/ui/src/components/chat/FileAttachment.tsx
+++ b/packages/ui/src/components/chat/FileAttachment.tsx
@@ -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 && (
{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 (
-
- {file.mime?.includes('pdf') ? (
-
- ) : (
-
- )}
-
-
{fileName}
+ {githubLinkKind && file.url ? (
+
+ ) : (
+
+ {file.mime?.includes('pdf') ? (
+
+ ) : (
+
+ )}
+
+ {fileName}
+
-
+ )}
{fileName}{sizeText ? ` (${sizeText})` : ''}
@@ -426,7 +468,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
{imageFiles.map((file, index) => {
- const filename = extractFilename(file.filename) || 'Image';
+ const filename = resolveDisplayName(file) || 'Image';
return (
@@ -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 (
+
+
+
+
+
+ {fileName}{sizeText ? ` (${sizeText})` : ''}
+
+
+ );
+ }
+
return (
diff --git a/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts b/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts
index e62f0129..d9954b42 100644
--- a/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts
+++ b/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts
@@ -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 = (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(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(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;
@@ -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;
}
diff --git a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx
index 4b92233d..d7273144 100644
--- a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx
+++ b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx
@@ -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 = ({
projectRef,
+ projectLabel,
canCreateWorktree = false,
onActionComplete,
className,
@@ -48,6 +50,7 @@ export const ProjectNotesTodoPanel: React.FC = ({
const [todos, setTodos] = React.useState([]);
const [newTodoText, setNewTodoText] = React.useState('');
const [sendingTodoId, setSendingTodoId] = React.useState(null);
+ const [expandedTodoIds, setExpandedTodoIds] = React.useState>(() => new Set());
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
@@ -77,6 +80,7 @@ export const ProjectNotesTodoPanel: React.FC = ({
setNotes('');
setTodos([]);
setNewTodoText('');
+ setExpandedTodoIds(new Set());
return;
}
@@ -92,6 +96,7 @@ export const ProjectNotesTodoPanel: React.FC = ({
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 = ({
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 = ({
-
Quick notes
+
+ Quick notes - {projectLabel?.trim() || projectRef.path.split('/').filter(Boolean).pop() || projectRef.path}
+
{notes.length}/{OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH}
-
Todo
+
Todo
{todos.length} item{todos.length === 1 ? '' : 's'}
+
{todoInputValue.length}/{OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH}
@@ -300,58 +322,69 @@ export const ProjectNotesTodoPanel: React.FC
= ({
No todos yet. Add a small checklist for this project.
) : (
- {todos.map((todo) => (
- -
+ {todos.map((todo) => {
+ const isExpandedTodo = expandedTodoIds.has(todo.id);
+ return (
+
-
handleToggleTodo(todo.id, checked)}
ariaLabel={`Mark "${todo.text}" complete`}
+ className="mt-[3px] self-start"
/>
-
- {todo.text}
-
-
-
-
-
-
- handleSendToCurrentSession(todo.text)}>
- Send to current session
-
- handleSendToNewSession(todo.text)}>
- Send to new session
-
- void handleSendToNewWorktreeSession(todo.id, todo.text)}
- disabled={!canCreateWorktree}
- >
- Send to new worktree session
-
-
-
+
+
+
+
+
+
+
+ handleSendToCurrentSession(todo.text)}>
+ Send to current session
+
+ handleSendToNewSession(todo.text)}>
+ Send to new session
+
+ void handleSendToNewWorktreeSession(todo.id, todo.text)}
+ disabled={!canCreateWorktree}
+ >
+ Send to new worktree session
+
+
+
+
- ))}
+ );
+ })}
)}
diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx
index 726d7db2..25239284 100644
--- a/packages/ui/src/components/session/SessionSidebar.tsx
+++ b/packages/ui/src/components/session/SessionSidebar.tsx
@@ -900,6 +900,14 @@ export const SessionSidebar: React.FC
= ({
: 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 = ({
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 = ({
onOpenShortcuts={toggleHelpDialog}
onOpenAbout={() => setAboutDialogOpen(true)}
onOpenUpdate={handleOpenUpdateDialog}
+ showRuntimeButtons={!isVSCode}
showUpdateButton={showSidebarUpdateButton}
/>
@@ -1433,10 +1448,11 @@ export const SessionSidebar: React.FC = ({
setProjectNotesPanelOpen(false)}
- title="Project notes"
+ title={activeProjectLabelForHeader ? `Project notes - ${activeProjectLabelForHeader}` : 'Project notes'}
>
setProjectNotesPanelOpen(false)}
className="p-0"
diff --git a/packages/ui/src/components/session/sidebar/SidebarFooter.tsx b/packages/ui/src/components/session/sidebar/SidebarFooter.tsx
index 4a555047..f1b29d77 100644
--- a/packages/ui/src/components/session/sidebar/SidebarFooter.tsx
+++ b/packages/ui/src/components/session/sidebar/SidebarFooter.tsx
@@ -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 (
-
-
-
-
- Settings
-
-
-
-
-
- Shortcuts
-
-
-
-
-
- About OpenChamber
-
+ {showRuntimeButtons ? (
+ <>
+
+
+
+
+ Settings
+
+
+
+
+
+ Shortcuts
+
+
+
+
+
+ About OpenChamber
+
+ >
+ ) : null}
{showUpdateButton ? (
+ {useMobileNotesPanel ? (
+
+
+
+
+ Project notes
+
+ ) : (
+
+
+
+
+
+
+
+ Project notes
+
+
+ setProjectNotesPanelOpen(false)}
+ />
+
+
+ )}
+