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}