From dd41962c48b227fc25991a8a06c04c514cf1959c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:55:17 +0000 Subject: [PATCH 01/41] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index cede929962d..5cf574ceace 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 1.6m + + downloads: 1.7m @@ -15,7 +15,7 @@ downloads downloads - 1.6m - 1.6m + 1.7m + 1.7m From 52b0f8bf6879d4a74beed9eceb6a7041c60d0c97 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:57:52 +0000 Subject: [PATCH 02/41] release: v1.4.96 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 74e9f4d6c7c..dd53f5545dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.96-rc.0", + "version": "1.4.96", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 20882d7a25c33a43b50ea19462a0e50f2deb11c4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:58:51 -0700 Subject: [PATCH 03/41] Increase Quick Command agent prompt limit (#6283) Co-authored-by: Orca --- src/shared/terminal-quick-commands.test.ts | 55 ++++++++++++++++++++++ src/shared/terminal-quick-commands.ts | 9 ++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/shared/terminal-quick-commands.test.ts b/src/shared/terminal-quick-commands.test.ts index c67cd358bf6..4a59c4ae40a 100644 --- a/src/shared/terminal-quick-commands.test.ts +++ b/src/shared/terminal-quick-commands.test.ts @@ -159,6 +159,61 @@ describe('terminal quick commands', () => { ]) }) + it('keeps larger reusable agent prompts while bounding shell commands separately', () => { + const largePrompt = 'Review this diff.\n'.repeat(320) + const overLimitPrompt = 'x'.repeat(6001) + const overLimitCommand = 'y'.repeat(4001) + + expect( + normalizeTerminalQuickCommands([ + { + id: 'large-review', + label: 'Review', + action: 'agent-prompt', + agent: 'codex', + prompt: largePrompt + }, + { + id: 'over-limit-review', + label: 'Review with cap', + action: 'agent-prompt', + agent: 'codex', + prompt: overLimitPrompt + }, + { + id: 'over-limit-command', + label: 'Run long command', + command: overLimitCommand + } + ]) + ).toEqual([ + { + id: 'large-review', + label: 'Review', + action: 'agent-prompt', + agent: 'codex', + prompt: largePrompt.trimEnd(), + scope: { type: 'global' } + }, + { + id: 'over-limit-review', + label: 'Review with cap', + action: 'agent-prompt', + agent: 'codex', + prompt: 'x'.repeat(6000), + scope: { type: 'global' } + }, + { + id: 'over-limit-command', + label: 'Run long command', + action: 'terminal-command', + command: 'y'.repeat(4000), + appendEnter: true, + scope: { type: 'global' } + } + ]) + }) + it('matches global commands everywhere and repo commands only in their repo', () => { expect( terminalQuickCommandMatchesRepo( diff --git a/src/shared/terminal-quick-commands.ts b/src/shared/terminal-quick-commands.ts index 2efffc37aef..2037a30b2b3 100644 --- a/src/shared/terminal-quick-commands.ts +++ b/src/shared/terminal-quick-commands.ts @@ -10,7 +10,10 @@ import type { const MAX_QUICK_COMMANDS = 40 const MAX_QUICK_COMMAND_LABEL_LENGTH = 80 const MAX_QUICK_COMMAND_REPO_ID_LENGTH = 200 -const MAX_QUICK_COMMAND_TEXT_LENGTH = 4000 +const MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH = 4000 +// Why: agent prompt quick commands still launch through startup commands for +// argv/flag agents, so this must stay within Orca's Windows shell safety cap. +const MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH = 6000 const REMOVED_PRESET_IDS = new Set(['default-pwd', 'default-git-status']) const DEFAULT_TERMINAL_QUICK_COMMANDS: TerminalQuickCommand[] = [] @@ -133,7 +136,7 @@ export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCom agent: agentId, prompt: (hasPrompt ? String(record.prompt).trimEnd() : '').slice( 0, - MAX_QUICK_COMMAND_TEXT_LENGTH + MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH ) }) } else { @@ -141,7 +144,7 @@ export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCom normalized.push({ ...base, action: 'terminal-command', - command: command.slice(0, MAX_QUICK_COMMAND_TEXT_LENGTH), + command: command.slice(0, MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH), appendEnter: record.appendEnter !== false }) } From 379ddf2a1b31e63dc1950fd53ac468cb002171ff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:03:23 -0700 Subject: [PATCH 04/41] Prevent visible agent terminals from sleeping (#6248) Co-authored-by: Orca --- .../components/terminal-pane/TerminalPane.tsx | 3 ++ ...se-visible-terminal-worktree-claim.test.ts | 54 +++++++++++++++++++ .../use-visible-terminal-worktree-claim.ts | 21 ++++++++ .../lib/agent-hibernation-coordinator.test.ts | 24 +++++++++ .../lib/foreground-terminal-worktrees.test.ts | 36 +++++++++++++ .../src/lib/foreground-terminal-worktrees.ts | 40 +++++++++++--- 6 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.test.ts create mode 100644 src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.ts create mode 100644 src/renderer/src/lib/foreground-terminal-worktrees.test.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index d49b48c0fa7..bda9d525d18 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -111,6 +111,7 @@ import { keybindingMatchesAction } from '../../../../shared/keybindings' import { pasteTerminalClipboard } from './terminal-clipboard-paste' import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-paste-recovery' import { restoreTerminalFitToDesktop, restoreTerminalFitsToDesktop } from './terminal-fit-restore' +import { useVisibleTerminalWorktreeClaim } from './use-visible-terminal-worktree-claim' // Why: registry lives in a leaf module so the store slice can import it // without re-entering the `slice → TerminalPane → store → slice` cycle @@ -261,6 +262,8 @@ export default function TerminalPane({ const isVisibleRef = useRef(isVisible) isVisibleRef.current = isVisible + useVisibleTerminalWorktreeClaim({ isVisible, worktreeId }) + const [expandedPaneId, setExpandedPaneId] = useState(null) // Why: tracked in React state (not derived from managerRef.getPanes().length) // so the render containing the portal map (which reads the imperative pane diff --git a/src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.test.ts b/src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.test.ts new file mode 100644 index 00000000000..79c17b4fcca --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.test.ts @@ -0,0 +1,54 @@ +import type * as ReactModule from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + getForegroundTerminalWorktreeIds, + resetForegroundTerminalWorktreeIdsForTests +} from '@/lib/foreground-terminal-worktrees' +import { useVisibleTerminalWorktreeClaim } from './use-visible-terminal-worktree-claim' + +const reactEffects = vi.hoisted(() => ({ + layoutEffects: [] as (() => void | (() => void))[], + passiveEffects: [] as (() => void | (() => void))[] +})) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + reactEffects.passiveEffects.push(effect) + }, + useLayoutEffect: (effect: () => void | (() => void)) => { + reactEffects.layoutEffects.push(effect) + } + } +}) + +afterEach(() => { + resetForegroundTerminalWorktreeIdsForTests() + reactEffects.layoutEffects = [] + reactEffects.passiveEffects = [] +}) + +describe('useVisibleTerminalWorktreeClaim', () => { + it('registers visible panes through a layout effect', () => { + useVisibleTerminalWorktreeClaim({ isVisible: true, worktreeId: 'wt-visible' }) + + expect(reactEffects.passiveEffects).toHaveLength(0) + expect(reactEffects.layoutEffects).toHaveLength(1) + + const cleanup = reactEffects.layoutEffects[0]() + expect(getForegroundTerminalWorktreeIds()).toEqual(['wt-visible']) + + cleanup?.() + expect(getForegroundTerminalWorktreeIds()).toEqual([]) + }) + + it('does not claim hidden panes', () => { + useVisibleTerminalWorktreeClaim({ isVisible: false, worktreeId: 'wt-hidden' }) + + reactEffects.layoutEffects[0]() + + expect(getForegroundTerminalWorktreeIds()).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.ts b/src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.ts new file mode 100644 index 00000000000..9cb69bf84af --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.ts @@ -0,0 +1,21 @@ +import { useLayoutEffect } from 'react' +import { registerVisibleTerminalWorktree } from '@/lib/foreground-terminal-worktrees' + +type VisibleTerminalWorktreeClaimOptions = { + isVisible: boolean + worktreeId: string +} + +export function useVisibleTerminalWorktreeClaim({ + isVisible, + worktreeId +}: VisibleTerminalWorktreeClaimOptions): void { + useLayoutEffect(() => { + if (!isVisible) { + return + } + // Why: agent sleep must fail closed before paint for any pane the user can + // see, even when global active-worktree state is between views. + return registerVisibleTerminalWorktree(worktreeId) + }, [isVisible, worktreeId]) +} diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts index 811b8ed48a4..297eb1b08db 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts @@ -9,6 +9,7 @@ import { } from './agent-hibernation-coordinator' import { hydrateDrivers, setDriverForPty } from './pane-manager/mobile-driver-state' import { + registerVisibleTerminalWorktree, resetForegroundTerminalWorktreeIdsForTests, setForegroundTerminalWorktreeIds } from './foreground-terminal-worktrees' @@ -239,6 +240,29 @@ describe('agent sleep coordinator', () => { expect(shutdown).not.toHaveBeenCalled() }) + it('does not hibernate a worktree with a visible mounted terminal pane', async () => { + vi.useFakeTimers() + const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined)) + const unregister = registerVisibleTerminalWorktree('wt-bg') + startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW }) + + await vi.advanceTimersByTimeAsync(3000) + expect(shutdown).not.toHaveBeenCalled() + + unregister() + // Why: the coordinator requires one tick to confirm a stable candidate + // and a second tick to revalidate before shutdown. + await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(1000) + + expect(shutdown).toHaveBeenCalledWith('wt-bg', { + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + leafId: LEAF, + ptyId: 'pty-1' + }) + }) + it('requires the same candidate signature during final revalidation', async () => { vi.useFakeTimers() const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined)) diff --git a/src/renderer/src/lib/foreground-terminal-worktrees.test.ts b/src/renderer/src/lib/foreground-terminal-worktrees.test.ts new file mode 100644 index 00000000000..dcffad49739 --- /dev/null +++ b/src/renderer/src/lib/foreground-terminal-worktrees.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + getForegroundTerminalWorktreeIds, + registerVisibleTerminalWorktree, + resetForegroundTerminalWorktreeIdsForTests, + setForegroundTerminalWorktreeIds +} from './foreground-terminal-worktrees' + +afterEach(() => { + resetForegroundTerminalWorktreeIdsForTests() +}) + +describe('foreground terminal worktrees', () => { + it('returns the union of explicit foreground ids and visible terminal claims', () => { + setForegroundTerminalWorktreeIds(['wt-explicit', null, '', undefined]) + const unregister = registerVisibleTerminalWorktree('wt-visible') + + expect(getForegroundTerminalWorktreeIds().sort()).toEqual(['wt-explicit', 'wt-visible']) + + unregister() + expect(getForegroundTerminalWorktreeIds()).toEqual(['wt-explicit']) + }) + + it('keeps duplicate visible worktree claims until every token unregisters', () => { + const unregisterFirst = registerVisibleTerminalWorktree('wt-visible') + const unregisterSecond = registerVisibleTerminalWorktree('wt-visible') + + expect(getForegroundTerminalWorktreeIds()).toEqual(['wt-visible']) + + unregisterFirst() + expect(getForegroundTerminalWorktreeIds()).toEqual(['wt-visible']) + + unregisterSecond() + expect(getForegroundTerminalWorktreeIds()).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/foreground-terminal-worktrees.ts b/src/renderer/src/lib/foreground-terminal-worktrees.ts index 6b1fa4943b1..338ec55ba08 100644 --- a/src/renderer/src/lib/foreground-terminal-worktrees.ts +++ b/src/renderer/src/lib/foreground-terminal-worktrees.ts @@ -1,19 +1,45 @@ -let foregroundWorktreeIds = new Set() +let explicitForegroundWorktreeIds = new Set() +const visibleTerminalClaimsByToken = new Map() -export function setForegroundTerminalWorktreeIds( - worktreeIds: Iterable -): void { - foregroundWorktreeIds = new Set( +function normalizeWorktreeIds(worktreeIds: Iterable): Set { + return new Set( Array.from(worktreeIds).filter( (worktreeId): worktreeId is string => typeof worktreeId === 'string' && worktreeId.length > 0 ) ) } +export function setForegroundTerminalWorktreeIds( + worktreeIds: Iterable +): void { + explicitForegroundWorktreeIds = normalizeWorktreeIds(worktreeIds) +} + +export function registerVisibleTerminalWorktree(worktreeId: string | null | undefined): () => void { + const normalized = normalizeWorktreeIds([worktreeId]) + const id = Array.from(normalized)[0] + if (!id) { + return () => {} + } + + // Why: multiple visible panes can belong to one worktree; tokenized claims + // let each pane clean up without dropping sibling foreground protection. + const token = Symbol(id) + visibleTerminalClaimsByToken.set(token, id) + return () => { + visibleTerminalClaimsByToken.delete(token) + } +} + export function getForegroundTerminalWorktreeIds(): string[] { - return Array.from(foregroundWorktreeIds) + // Why: hibernation already gates by foreground worktree, so visible pane + // claims join the page-level foreground set instead of adding pane rules. + return Array.from( + new Set([...explicitForegroundWorktreeIds, ...visibleTerminalClaimsByToken.values()]) + ) } export function resetForegroundTerminalWorktreeIdsForTests(): void { - foregroundWorktreeIds = new Set() + explicitForegroundWorktreeIds = new Set() + visibleTerminalClaimsByToken.clear() } From a1ec8d25a7511330c84ee85d0d0cc2953506fbe4 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:11:08 -0700 Subject: [PATCH 05/41] Fix local markdown image resolution and PDF export inlining (#6253) * Resolve local images on editor context switch and inline blobs for PDF * Inline renderer-scoped blob URLs as base64 data URLs during PDF export, ensuring images render properly in the separate PDF print window. * Track the active editor document's context with a versioning and subscription scheme to trigger reloads of relative images on context switch. * test commit --- .../editor/export-active-markdown.test.ts | 55 ++++ .../editor/export-active-markdown.ts | 15 +- .../editor/markdown-export-extract.test.ts | 69 ++++ .../editor/markdown-export-extract.ts | 44 ++- .../editor/rich-markdown-editor-config.ts | 265 ++++++++++++++++ .../editor/rich-markdown-extensions.ts | 31 +- .../editor/rich-markdown-image-context.ts | 89 ++++++ .../editor/rich-markdown-local-image.test.ts | 66 ++++ .../editor/useRichMarkdownEditorInstance.ts | 294 +----------------- .../editor/useRichMarkdownProgrammaticSync.ts | 39 +-- 10 files changed, 648 insertions(+), 319 deletions(-) create mode 100644 src/renderer/src/components/editor/export-active-markdown.test.ts create mode 100644 src/renderer/src/components/editor/markdown-export-extract.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-editor-config.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-image-context.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-local-image.test.ts diff --git a/src/renderer/src/components/editor/export-active-markdown.test.ts b/src/renderer/src/components/editor/export-active-markdown.test.ts new file mode 100644 index 00000000000..0dc3ff552ae --- /dev/null +++ b/src/renderer/src/components/editor/export-active-markdown.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { exportActiveMarkdownToPdf } from './export-active-markdown' +import { getActiveMarkdownExportPayload } from './markdown-export-extract' + +vi.mock('sonner', () => ({ + toast: { + dismiss: vi.fn(), + error: vi.fn(), + loading: vi.fn(() => 'toast-id'), + success: vi.fn() + } +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('./markdown-export-extract', () => ({ + getActiveMarkdownExportPayload: vi.fn() +})) + +describe('exportActiveMarkdownToPdf', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + export: { + htmlToPdf: vi.fn() + } + } + }) + }) + + it('surfaces payload extraction failures through the export toast', async () => { + vi.mocked(getActiveMarkdownExportPayload).mockRejectedValue( + new Error('Failed to inline image for PDF export: Unable to fetch blob image') + ) + + await exportActiveMarkdownToPdf({ + fileId: '/repo/docs/readme.md', + root: document.createElement('div') + }) + + expect(toast.loading).toHaveBeenCalledWith('Exporting PDF...') + expect(window.api.export.htmlToPdf).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith( + 'Failed to inline image for PDF export: Unable to fetch blob image', + { id: 'toast-id' } + ) + }) +}) diff --git a/src/renderer/src/components/editor/export-active-markdown.ts b/src/renderer/src/components/editor/export-active-markdown.ts index cf9f94eb031..fc589070257 100644 --- a/src/renderer/src/components/editor/export-active-markdown.ts +++ b/src/renderer/src/components/editor/export-active-markdown.ts @@ -10,17 +10,18 @@ export async function exportActiveMarkdownToPdf(options: { fileId: string root: ParentNode | null }): Promise { - const payload = getActiveMarkdownExportPayload(options) - if (!payload) { - // Why: stale panel refs can survive a dropdown click; keep export defensive - // even though the local Markdown menu disables unreachable states. - return - } - const toastId = toast.loading( translate('auto.components.editor.export.active.markdown.d4a901e0ad', 'Exporting PDF...') ) try { + const payload = await getActiveMarkdownExportPayload(options) + if (!payload) { + // Why: stale panel refs can survive a dropdown click; keep export defensive + // even though the local Markdown menu disables unreachable states. + toast.dismiss(toastId) + return + } + const result = await window.api.export.htmlToPdf({ html: payload.html, title: payload.title diff --git a/src/renderer/src/components/editor/markdown-export-extract.test.ts b/src/renderer/src/components/editor/markdown-export-extract.test.ts new file mode 100644 index 00000000000..f10a8289c44 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-export-extract.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getActiveMarkdownExportPayload } from './markdown-export-extract' + +vi.mock('@/store', () => ({ + useAppStore: { + getState: vi.fn() + } +})) + +describe('getActiveMarkdownExportPayload', () => { + beforeEach(async () => { + vi.clearAllMocks() + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + blob: async () => new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }) + }) + ) + const { useAppStore } = await import('@/store') + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: [ + { + id: '/repo/docs/readme.md', + filePath: '/repo/docs/readme.md', + relativePath: 'docs/readme.md', + mode: 'edit' + } + ] + } as never) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('embeds blob image sources so the PDF export window can render local images', async () => { + const root = document.createElement('div') + root.innerHTML = + '

diagram

' + + const payload = await getActiveMarkdownExportPayload({ + fileId: '/repo/docs/readme.md', + root + }) + + expect(fetch).toHaveBeenCalledWith('blob:rich-local-image') + expect(payload?.html).toContain('src="data:image/png;base64,AQID"') + expect(payload?.html).not.toContain('blob:rich-local-image') + }) + + it('fails extraction when a blob image cannot be inlined', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false + } as Response) + const root = document.createElement('div') + root.innerHTML = + '

diagram

' + + await expect( + getActiveMarkdownExportPayload({ + fileId: '/repo/docs/readme.md', + root + }) + ).rejects.toThrow('Failed to inline image for PDF export') + }) +}) diff --git a/src/renderer/src/components/editor/markdown-export-extract.ts b/src/renderer/src/components/editor/markdown-export-extract.ts index 8a84df41be4..a0ec5ebb2fe 100644 --- a/src/renderer/src/components/editor/markdown-export-extract.ts +++ b/src/renderer/src/components/editor/markdown-export-extract.ts @@ -40,13 +40,13 @@ function findDocumentSubtree(root: ParentNode): Element | null { * markdown surface. Returns null when the requested file is stale or the * surface is in a mode (Monaco source) that does not render a document DOM. */ -export function getActiveMarkdownExportPayload({ +export async function getActiveMarkdownExportPayload({ fileId, root }: { fileId: string root: ParentNode | null -}): MarkdownExportPayload | null { +}): Promise { if (!root) { return null } @@ -71,6 +71,9 @@ export function getActiveMarkdownExportPayload({ node.remove() } } + // Why: local-image previews use renderer-scoped blob URLs; the hidden PDF + // window cannot dereference them, so embed the bytes before export. + await inlineBlobImageSources(clone) const renderedHtml = clone.innerHTML.trim() if (!renderedHtml) { @@ -81,3 +84,40 @@ export function getActiveMarkdownExportPayload({ const html = buildMarkdownExportHtml({ title, renderedHtml }) return { title, html } } + +async function inlineBlobImageSources(root: Element): Promise { + const images = Array.from(root.querySelectorAll('img[src^="blob:"]')) + await Promise.all( + images.map(async (image) => { + const src = image.getAttribute('src') + if (!src) { + return + } + image.setAttribute('src', await readBlobImageAsDataUrl(src)) + }) + ) +} + +async function readBlobImageAsDataUrl(src: string): Promise { + try { + const response = await fetch(src) + if (!response.ok) { + throw new Error('Unable to fetch blob image') + } + const blob = await response.blob() + const bytes = new Uint8Array(await blob.arrayBuffer()) + return `data:${blob.type || 'application/octet-stream'};base64,${bytesToBase64(bytes)}` + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to inline image for PDF export: ${message}`) + } +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + const chunkSize = 0x8000 + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)) + } + return btoa(binary) +} diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts new file mode 100644 index 00000000000..e6fedd8f897 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -0,0 +1,265 @@ +import type { Editor, UseEditorOptions } from '@tiptap/react' +import { handleRichMarkdownCut } from './rich-markdown-cut-handler' +import { handleRichMarkdownPaste } from './rich-markdown-paste-handler' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { normalizeSoftBreaks } from './rich-markdown-normalize' +import { autoFocusRichEditor } from './rich-markdown-auto-focus' +import { + syncSlashMenu, + type SlashCommand, + type SlashMenuState +} from './rich-markdown-slash-commands' +import { + syncDocLinkMenu, + type DocLinkMenuRow, + type DocLinkMenuState +} from './rich-markdown-commands' +import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation' +import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble' +import { + handleRichMarkdownEditorClick, + type ActivateMarkdownLink, + type RichMarkdownRuntimeSettings +} from './rich-markdown-editor-click-routing' +import { createRichMarkdownKeyHandler } from './rich-markdown-key-handler' +import { + createRichMarkdownImageResolverContext, + setRichMarkdownImageResolverContext +} from './rich-markdown-image-context' +import type { MutableRefObject, Dispatch, SetStateAction } from 'react' +import type { DiffComment } from '../../../../shared/types' + +export type EditorConfigParams = { + content: string + filePath: string + worktreeId: string + worktreeRoot: string | null + runtimeEnvironmentId?: string | null + isMac: boolean + settings: RichMarkdownRuntimeSettings + activateMarkdownLink: ActivateMarkdownLink + rootRef: MutableRefObject + editorRef: MutableRefObject + lastCommittedMarkdownRef: MutableRefObject + onContentChangeRef: MutableRefObject<(content: string) => void> + onDirtyStateHintRef: MutableRefObject<(dirty: boolean) => void> + onSaveRef: MutableRefObject<(content: string) => void> + onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined> + isEditingLinkRef: MutableRefObject + slashMenuRef: MutableRefObject + filteredSlashCommandsRef: MutableRefObject + selectedCommandIndexRef: MutableRefObject + docLinkMenuRef: MutableRefObject + filteredDocLinkRowsRef: MutableRefObject + selectedDocLinkIndexRef: MutableRefObject + handleLocalImagePickRef: MutableRefObject<() => void> + handleEmojiPickRef: MutableRefObject<(menu: SlashMenuState) => void> + typedEmptyOrderedListMarkerRef: MutableRefObject + cancelAutoFocusRef: MutableRefObject<(() => void) | null> + serializeTimerRef: MutableRefObject + isInitializingRef: MutableRefObject + isApplyingProgrammaticUpdateRef: MutableRefObject + markdownCommentsRef: MutableRefObject + markdownSourceLineOffsetRef: MutableRefObject + flushPendingSerialization: () => void + openSearchRef: MutableRefObject<() => void> + syncAnnotationTarget: (editor: Editor) => void + clearAnnotationTarget: () => void + scrollRichMarkdownReviewNoteCardIntoView: (commentId: string) => void + setIsEditingLink: Dispatch> + setLinkBubble: Dispatch> + setSelectedCommandIndex: Dispatch> + setSelectedDocLinkIndex: Dispatch> + setSlashMenu: Dispatch> + setDocLinkMenu: Dispatch> +} + +export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseEditorOptions { + const { + content, + filePath, + worktreeId, + worktreeRoot, + runtimeEnvironmentId, + isMac, + settings, + activateMarkdownLink, + rootRef, + editorRef, + lastCommittedMarkdownRef, + onContentChangeRef, + onDirtyStateHintRef, + onSaveRef, + onOpenDocLinkRef, + isEditingLinkRef, + slashMenuRef, + filteredSlashCommandsRef, + selectedCommandIndexRef, + docLinkMenuRef, + filteredDocLinkRowsRef, + selectedDocLinkIndexRef, + handleLocalImagePickRef, + handleEmojiPickRef, + typedEmptyOrderedListMarkerRef, + cancelAutoFocusRef, + serializeTimerRef, + isInitializingRef, + isApplyingProgrammaticUpdateRef, + markdownCommentsRef, + markdownSourceLineOffsetRef, + flushPendingSerialization, + openSearchRef, + syncAnnotationTarget, + clearAnnotationTarget, + scrollRichMarkdownReviewNoteCardIntoView, + setIsEditingLink, + setLinkBubble, + setSelectedCommandIndex, + setSelectedDocLinkIndex, + setSlashMenu, + setDocLinkMenu + } = params + + return { + immediatelyRender: false, + content: encodeRawMarkdownHtmlForRichEditor(content), + contentType: 'markdown' as const, + editorProps: { + attributes: { + class: 'rich-markdown-editor', + spellcheck: 'true' + }, + handleDOMEvents: { + cut: handleRichMarkdownCut + }, + handlePaste: (_view, event) => + handleRichMarkdownPaste({ + editor: editorRef.current, + event, + filePath, + worktreeId, + runtimeEnvironmentId + }), + handleTextInput: (view, from, to, text) => { + typedEmptyOrderedListMarkerRef.current = false + if (text !== ' ' || from !== to || !view.state.selection.empty) { + return false + } + const { $from } = view.state.selection + const beforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0') + typedEmptyOrderedListMarkerRef.current = /^\d+\.$/.test(beforeCursor) + return false + }, + handleKeyDown: createRichMarkdownKeyHandler({ + isMac, + editorRef, + rootRef, + lastCommittedMarkdownRef, + onContentChangeRef, + onSaveRef, + isEditingLinkRef, + slashMenuRef, + filteredSlashCommandsRef, + selectedCommandIndexRef, + docLinkMenuRef, + filteredDocLinkRowsRef, + selectedDocLinkIndexRef, + handleLocalImagePickRef, + handleEmojiPickRef, + typedEmptyOrderedListMarkerRef, + flushPendingSerialization, + openSearchRef, + setIsEditingLink, + setLinkBubble, + setSelectedCommandIndex, + setSelectedDocLinkIndex, + setSlashMenu, + setDocLinkMenu + }), + handleClick: (view, pos, event) => { + return handleRichMarkdownEditorClick({ + activateMarkdownLink, + editorRef, + event, + filePath, + isMac, + markdownCommentsRef, + markdownSourceLineOffsetRef, + onOpenDocLinkRef, + pos, + rootRef, + runtimeEnvironmentId, + scrollRichMarkdownReviewNoteCardIntoView, + settings, + view, + worktreeId, + worktreeRoot + }) + } + }, + onFocus: () => { + window.api.ui.setMarkdownEditorFocused(true) + }, + onBlur: () => { + window.api.ui.setMarkdownEditorFocused(false) + clearAnnotationTarget() + }, + onCreate: ({ editor: nextEditor }) => { + normalizeSoftBreaks(nextEditor) + lastCommittedMarkdownRef.current = content + isInitializingRef.current = false + cancelAutoFocusRef.current?.() + cancelAutoFocusRef.current = autoFocusRichEditor(nextEditor, rootRef.current) + }, + onBeforeCreate: ({ editor: nextEditor }) => { + setRichMarkdownImageResolverContext( + nextEditor, + createRichMarkdownImageResolverContext({ + filePath, + runtimeEnvironmentId, + settings, + worktreeId, + worktreeRoot + }) + ) + }, + onUpdate: ({ editor: nextEditor }) => { + syncSlashMenu(nextEditor, rootRef.current, setSlashMenu) + syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu) + if (!isSingleEmptyTopLevelOrderedList(nextEditor)) { + typedEmptyOrderedListMarkerRef.current = false + } + if (isInitializingRef.current || isApplyingProgrammaticUpdateRef.current) { + return + } + onDirtyStateHintRef.current(true) + if (serializeTimerRef.current !== null) { + window.clearTimeout(serializeTimerRef.current) + } + serializeTimerRef.current = window.setTimeout(() => { + serializeTimerRef.current = null + try { + const markdown = nextEditor.getMarkdown() + lastCommittedMarkdownRef.current = markdown + onContentChangeRef.current(markdown) + } catch { + // Why: save/restart flows should never crash the UI just because + // the editor was torn down between scheduling and serializing. + } + }, 300) + }, + onSelectionUpdate: ({ editor: nextEditor }) => { + syncSlashMenu(nextEditor, rootRef.current, setSlashMenu) + syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu) + syncAnnotationTarget(nextEditor) + setIsEditingLink(false) + if (nextEditor.isActive('link')) { + const attrs = nextEditor.getAttributes('link') + const pos = getLinkBubblePosition(nextEditor, rootRef.current) + setLinkBubble(pos ? { href: (attrs.href as string) || '', ...pos } : null) + } else { + setLinkBubble(null) + } + } + } +} diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index 6d2dd37cae7..c5d96c452e7 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -60,7 +60,12 @@ export function createRichMarkdownExtensions({ // and works identically in dev and production modes. Image.extend({ addStorage() { - return { filePath: '', runtimeContext: undefined as RuntimeFileOperationArgs | undefined } + return { + contextVersion: 0, + filePath: '', + reloadListeners: new Set<() => void>(), + runtimeContext: undefined as RuntimeFileOperationArgs | undefined + } }, addNodeView() { return ({ node, HTMLAttributes }) => { @@ -81,15 +86,17 @@ export function createRichMarkdownExtensions({ dom.appendChild(img) let currentSrc = node.attrs.src as string | undefined + let currentContextVersion = getImageContextVersion(this.storage) const loadImage = (src: string | undefined): void => { const fp = this.storage.filePath as string const runtimeContext = this.storage.runtimeContext as | RuntimeFileOperationArgs | undefined + const contextVersionAtLoad = getImageContextVersion(this.storage) if (src && fp) { void loadLocalImageSrc(src, fp, undefined, runtimeContext).then((resolved) => { - if (currentSrc !== src) { + if (currentSrc !== src || currentContextVersion !== contextVersionAtLoad) { return } if (resolved) { @@ -116,6 +123,14 @@ export function createRichMarkdownExtensions({ const unsubscribe = onImageCacheInvalidated(() => { loadImage(currentSrc) }) + const reloadForContextChange = (): void => { + currentContextVersion = getImageContextVersion(this.storage) + loadImage(currentSrc) + } + const reloadListeners = this.storage.reloadListeners + if (reloadListeners instanceof Set) { + reloadListeners.add(reloadForContextChange) + } return { dom, @@ -124,13 +139,18 @@ export function createRichMarkdownExtensions({ return false } const newSrc = updatedNode.attrs.src as string | undefined - if (newSrc !== currentSrc) { + const nextContextVersion = getImageContextVersion(this.storage) + if (newSrc !== currentSrc || nextContextVersion !== currentContextVersion) { currentSrc = newSrc + currentContextVersion = nextContextVersion loadImage(newSrc) } return true }, destroy: () => { + if (reloadListeners instanceof Set) { + reloadListeners.delete(reloadForContextChange) + } unsubscribe() } } @@ -184,3 +204,8 @@ export function createRichMarkdownExtensions({ return extensions } + +function getImageContextVersion(storage: Record): number { + const version = storage.contextVersion + return typeof version === 'number' ? version : 0 +} diff --git a/src/renderer/src/components/editor/rich-markdown-image-context.ts b/src/renderer/src/components/editor/rich-markdown-image-context.ts new file mode 100644 index 00000000000..bc7e4b9f5c8 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-image-context.ts @@ -0,0 +1,89 @@ +import type { Editor } from '@tiptap/core' +import { getConnectionId } from '@/lib/connection-context' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' + +export type RichMarkdownImageRuntimeContext = Omit & { + connectionId?: string | null +} + +export type RichMarkdownImageResolverContext = { + filePath: string + runtimeContext?: RichMarkdownImageRuntimeContext +} + +export type RichMarkdownImageResolverSettings = Parameters[0] + +type RichMarkdownImageStorage = { + image?: { + contextVersion?: number + filePath: string + reloadListeners?: Set<() => void> + runtimeContext?: RichMarkdownImageRuntimeContext + } +} + +export function createRichMarkdownImageResolverContext({ + filePath, + runtimeEnvironmentId, + settings, + worktreeId, + worktreeRoot +}: { + filePath: string + runtimeEnvironmentId?: string | null + settings: RichMarkdownImageResolverSettings + worktreeId: string + worktreeRoot: string | null +}): RichMarkdownImageResolverContext { + return { + filePath, + runtimeContext: worktreeRoot + ? { + settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId), + worktreeId, + worktreePath: worktreeRoot, + connectionId: getConnectionId(worktreeId) + } + : undefined + } +} + +export function setRichMarkdownImageResolverContext( + editor: Editor, + context: RichMarkdownImageResolverContext +): boolean { + const storage = editor.storage as unknown as RichMarkdownImageStorage + const imageStorage = storage.image ?? { + filePath: '' + } + const previousSignature = getRichMarkdownImageContextSignature({ + filePath: imageStorage.filePath, + runtimeContext: imageStorage.runtimeContext + }) + const nextSignature = getRichMarkdownImageContextSignature(context) + if (previousSignature === nextSignature) { + return false + } + + // Why: nodeViews need a cheap change signal because the markdown src can + // remain identical while the file/runtime resolver context changes. + imageStorage.filePath = context.filePath + imageStorage.runtimeContext = context.runtimeContext + imageStorage.contextVersion = (imageStorage.contextVersion ?? 0) + 1 + storage.image = imageStorage + for (const listener of imageStorage.reloadListeners ?? []) { + listener() + } + return true +} + +function getRichMarkdownImageContextSignature(context: RichMarkdownImageResolverContext): string { + return [ + context.filePath, + context.runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client', + context.runtimeContext?.connectionId ?? 'local', + context.runtimeContext?.worktreeId ?? 'unknown-worktree', + context.runtimeContext?.worktreePath ?? '' + ].join('\0') +} diff --git a/src/renderer/src/components/editor/rich-markdown-local-image.test.ts b/src/renderer/src/components/editor/rich-markdown-local-image.test.ts new file mode 100644 index 00000000000..277c0a088dd --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-local-image.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom + +import { Editor } from '@tiptap/core' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { resetLocalImageSrcStateForTests } from './useLocalImageSrc' +import { setRichMarkdownImageResolverContext } from './rich-markdown-image-context' + +async function flushPromises(): Promise { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve() + } + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('rich markdown local images', () => { + beforeEach(() => { + resetLocalImageSrcStateForTests() + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:rich-local-image') + globalThis.window.api = { + ...globalThis.window.api, + fs: { + readFile: vi.fn().mockResolvedValue({ + content: 'AA==', + isBinary: true, + mimeType: 'image/png' + }) + } + } as unknown as Window['api'] + }) + + afterEach(() => { + resetLocalImageSrcStateForTests() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('reloads persisted relative images after the markdown file context is assigned', async () => { + const host = document.createElement('div') + document.body.appendChild(host) + const editor = new Editor({ + element: host, + extensions: createRichMarkdownExtensions(), + content: '![](diagram.png)', + contentType: 'markdown' + }) + + try { + const img = host.querySelector('img') + expect(img).not.toBeNull() + expect(window.api.fs.readFile).not.toHaveBeenCalled() + + setRichMarkdownImageResolverContext(editor, { filePath: '/repo/docs/readme.md' }) + + await flushPromises() + + expect(window.api.fs.readFile).toHaveBeenCalledWith({ + filePath: '/repo/docs/diagram.png', + connectionId: undefined + }) + expect(host.querySelector('img')?.src).toBe('blob:rich-local-image') + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts index 7e59b3289ce..cd2cf41a1aa 100644 --- a/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts +++ b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.ts @@ -1,299 +1,25 @@ -import { type Dispatch, type MutableRefObject, type SetStateAction, useMemo } from 'react' +import { useMemo } from 'react' import { useEditor, type Editor } from '@tiptap/react' import { createRichMarkdownExtensions } from './rich-markdown-extensions' -import { createRichMarkdownKeyHandler } from './rich-markdown-key-handler' -import { handleRichMarkdownCut } from './rich-markdown-cut-handler' -import { handleRichMarkdownPaste } from './rich-markdown-paste-handler' -import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' -import { normalizeSoftBreaks } from './rich-markdown-normalize' -import { autoFocusRichEditor } from './rich-markdown-auto-focus' import { - syncDocLinkMenu, - type DocLinkMenuRow, - type DocLinkMenuState -} from './rich-markdown-commands' -import { - syncSlashMenu, - type SlashCommand, - type SlashMenuState -} from './rich-markdown-slash-commands' -import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation' -import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble' -import { - handleRichMarkdownEditorClick, - type ActivateMarkdownLink, - type RichMarkdownRuntimeSettings -} from './rich-markdown-editor-click-routing' -import type { DiffComment } from '../../../../shared/types' + createRichMarkdownEditorConfig, + type EditorConfigParams +} from './rich-markdown-editor-config' const richMarkdownExtensions = createRichMarkdownExtensions({ includePlaceholder: true }) -export function useRichMarkdownEditorInstance({ - content, - filePath, - worktreeId, - worktreeRoot, - runtimeEnvironmentId, - isMac, - settings, - activateMarkdownLink, - rootRef, - editorRef, - lastCommittedMarkdownRef, - onContentChangeRef, - onDirtyStateHintRef, - onSaveRef, - onOpenDocLinkRef, - isEditingLinkRef, - slashMenuRef, - filteredSlashCommandsRef, - selectedCommandIndexRef, - docLinkMenuRef, - filteredDocLinkRowsRef, - selectedDocLinkIndexRef, - handleLocalImagePickRef, - handleEmojiPickRef, - typedEmptyOrderedListMarkerRef, - cancelAutoFocusRef, - serializeTimerRef, - isInitializingRef, - isApplyingProgrammaticUpdateRef, - markdownCommentsRef, - markdownSourceLineOffsetRef, - flushPendingSerialization, - openSearchRef, - syncAnnotationTarget, - clearAnnotationTarget, - scrollRichMarkdownReviewNoteCardIntoView, - setIsEditingLink, - setLinkBubble, - setSelectedCommandIndex, - setSelectedDocLinkIndex, - setSlashMenu, - setDocLinkMenu -}: { - content: string - filePath: string - worktreeId: string - worktreeRoot: string | null - runtimeEnvironmentId?: string | null - isMac: boolean - settings: RichMarkdownRuntimeSettings - activateMarkdownLink: ActivateMarkdownLink - rootRef: MutableRefObject - editorRef: MutableRefObject - lastCommittedMarkdownRef: MutableRefObject - onContentChangeRef: MutableRefObject<(content: string) => void> - onDirtyStateHintRef: MutableRefObject<(dirty: boolean) => void> - onSaveRef: MutableRefObject<(content: string) => void> - onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined> - isEditingLinkRef: MutableRefObject - slashMenuRef: MutableRefObject - filteredSlashCommandsRef: MutableRefObject - selectedCommandIndexRef: MutableRefObject - docLinkMenuRef: MutableRefObject - filteredDocLinkRowsRef: MutableRefObject - selectedDocLinkIndexRef: MutableRefObject - handleLocalImagePickRef: MutableRefObject<() => void> - handleEmojiPickRef: MutableRefObject<(menu: SlashMenuState) => void> - typedEmptyOrderedListMarkerRef: MutableRefObject - cancelAutoFocusRef: MutableRefObject<(() => void) | null> - serializeTimerRef: MutableRefObject - isInitializingRef: MutableRefObject - isApplyingProgrammaticUpdateRef: MutableRefObject - markdownCommentsRef: MutableRefObject - markdownSourceLineOffsetRef: MutableRefObject - flushPendingSerialization: () => void - openSearchRef: MutableRefObject<() => void> - syncAnnotationTarget: (editor: Editor) => void - clearAnnotationTarget: () => void - scrollRichMarkdownReviewNoteCardIntoView: (commentId: string) => void - setIsEditingLink: Dispatch> - setLinkBubble: Dispatch> - setSelectedCommandIndex: Dispatch> - setSelectedDocLinkIndex: Dispatch> - setSlashMenu: Dispatch> - setDocLinkMenu: Dispatch> -}): Editor | null { +export function useRichMarkdownEditorInstance(params: EditorConfigParams): Editor | null { const editor = useEditor( useMemo( () => ({ - immediatelyRender: false, extensions: richMarkdownExtensions, - content: encodeRawMarkdownHtmlForRichEditor(content), - contentType: 'markdown' as const, - editorProps: { - attributes: { - class: 'rich-markdown-editor', - spellcheck: 'true' - }, - handleDOMEvents: { - cut: handleRichMarkdownCut - }, - handlePaste: (_view, event) => - handleRichMarkdownPaste({ - editor: editorRef.current, - event, - filePath, - worktreeId, - runtimeEnvironmentId - }), - handleTextInput: (view, from, to, text) => { - typedEmptyOrderedListMarkerRef.current = false - if (text !== ' ' || from !== to || !view.state.selection.empty) { - return false - } - const { $from } = view.state.selection - const beforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0') - typedEmptyOrderedListMarkerRef.current = /^\d+\.$/.test(beforeCursor) - return false - }, - handleKeyDown: createRichMarkdownKeyHandler({ - isMac, - editorRef, - rootRef, - lastCommittedMarkdownRef, - onContentChangeRef, - onSaveRef, - isEditingLinkRef, - slashMenuRef, - filteredSlashCommandsRef, - selectedCommandIndexRef, - docLinkMenuRef, - filteredDocLinkRowsRef, - selectedDocLinkIndexRef, - handleLocalImagePickRef, - handleEmojiPickRef, - typedEmptyOrderedListMarkerRef, - flushPendingSerialization, - openSearchRef, - setIsEditingLink, - setLinkBubble, - setSelectedCommandIndex, - setSelectedDocLinkIndex, - setSlashMenu, - setDocLinkMenu - }), - handleClick: (view, pos, event) => { - return handleRichMarkdownEditorClick({ - activateMarkdownLink, - editorRef, - event, - filePath, - isMac, - markdownCommentsRef, - markdownSourceLineOffsetRef, - onOpenDocLinkRef, - pos, - rootRef, - runtimeEnvironmentId, - scrollRichMarkdownReviewNoteCardIntoView, - settings, - view, - worktreeId, - worktreeRoot - }) - } - }, - onFocus: () => { - window.api.ui.setMarkdownEditorFocused(true) - }, - onBlur: () => { - window.api.ui.setMarkdownEditorFocused(false) - clearAnnotationTarget() - }, - onCreate: ({ editor: nextEditor }) => { - normalizeSoftBreaks(nextEditor) - lastCommittedMarkdownRef.current = content - isInitializingRef.current = false - cancelAutoFocusRef.current?.() - cancelAutoFocusRef.current = autoFocusRichEditor(nextEditor, rootRef.current) - }, - onUpdate: ({ editor: nextEditor }) => { - syncSlashMenu(nextEditor, rootRef.current, setSlashMenu) - syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu) - if (!isSingleEmptyTopLevelOrderedList(nextEditor)) { - typedEmptyOrderedListMarkerRef.current = false - } - if (isInitializingRef.current || isApplyingProgrammaticUpdateRef.current) { - return - } - onDirtyStateHintRef.current(true) - if (serializeTimerRef.current !== null) { - window.clearTimeout(serializeTimerRef.current) - } - serializeTimerRef.current = window.setTimeout(() => { - serializeTimerRef.current = null - try { - const markdown = nextEditor.getMarkdown() - lastCommittedMarkdownRef.current = markdown - onContentChangeRef.current(markdown) - } catch { - // Why: save/restart flows should never crash the UI just because - // the editor was torn down between scheduling and serializing. - } - }, 300) - }, - onSelectionUpdate: ({ editor: nextEditor }) => { - syncSlashMenu(nextEditor, rootRef.current, setSlashMenu) - syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu) - syncAnnotationTarget(nextEditor) - setIsEditingLink(false) - if (nextEditor.isActive('link')) { - const attrs = nextEditor.getAttributes('link') - const pos = getLinkBubblePosition(nextEditor, rootRef.current) - setLinkBubble(pos ? { href: (attrs.href as string) || '', ...pos } : null) - } else { - setLinkBubble(null) - } - } + ...createRichMarkdownEditorConfig(params) }), - [ - activateMarkdownLink, - cancelAutoFocusRef, - clearAnnotationTarget, - content, - docLinkMenuRef, - editorRef, - filePath, - filteredDocLinkRowsRef, - filteredSlashCommandsRef, - flushPendingSerialization, - handleEmojiPickRef, - handleLocalImagePickRef, - isApplyingProgrammaticUpdateRef, - isEditingLinkRef, - isInitializingRef, - isMac, - lastCommittedMarkdownRef, - markdownCommentsRef, - markdownSourceLineOffsetRef, - onContentChangeRef, - onDirtyStateHintRef, - onOpenDocLinkRef, - onSaveRef, - openSearchRef, - rootRef, - runtimeEnvironmentId, - scrollRichMarkdownReviewNoteCardIntoView, - selectedCommandIndexRef, - selectedDocLinkIndexRef, - serializeTimerRef, - setDocLinkMenu, - setIsEditingLink, - setLinkBubble, - setSelectedCommandIndex, - setSelectedDocLinkIndex, - setSlashMenu, - settings, - slashMenuRef, - syncAnnotationTarget, - typedEmptyOrderedListMarkerRef, - worktreeId, - worktreeRoot - ] + // Dependencies are the same as the params object keys + // eslint-disable-next-line react-hooks/exhaustive-deps + Object.values(params) ) ) - editorRef.current = editor ?? null + params.editorRef.current = editor ?? null return editor } diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts index 0b6f33e14a0..db34644c66a 100644 --- a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts @@ -1,13 +1,16 @@ import { useEffect } from 'react' import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import type { Editor } from '@tiptap/react' -import { getConnectionId } from '@/lib/connection-context' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import type { MarkdownDocument } from '../../../../shared/types' import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' import { syncDocLinkMenu, type DocLinkMenuState } from './rich-markdown-commands' import { normalizeSoftBreaks } from './rich-markdown-normalize' import { syncSlashMenu, type SlashMenuState } from './rich-markdown-slash-commands' +import { + createRichMarkdownImageResolverContext, + setRichMarkdownImageResolverContext, + type RichMarkdownImageResolverSettings +} from './rich-markdown-image-context' type RichMarkdownProgrammaticSyncOptions = { content: string @@ -20,22 +23,13 @@ type RichMarkdownProgrammaticSyncOptions = { markdownDocuments?: MarkdownDocument[] rootRef: MutableRefObject runtimeEnvironmentId?: string | null - settings: Parameters[0] + settings: RichMarkdownImageResolverSettings slashMenuSetter: Dispatch> worktreeId: string worktreeRoot: string | null } type RichMarkdownEditorStorage = { - image: { - filePath: string - runtimeContext?: { - connectionId: string | null | undefined - settings: ReturnType - worktreeId: string - worktreePath: string - } - } markdownDocLink: { documents: MarkdownDocument[] } @@ -63,17 +57,16 @@ export function useRichMarkdownProgrammaticSync({ } isApplyingProgrammaticUpdateRef.current = true try { - const storage = editor.storage as unknown as RichMarkdownEditorStorage - storage.image.filePath = filePath - storage.image.runtimeContext = worktreeRoot - ? { - settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId), - worktreeId, - worktreePath: worktreeRoot, - connectionId: getConnectionId(worktreeId) - } - : undefined - editor.view.dispatch(editor.state.tr) + setRichMarkdownImageResolverContext( + editor, + createRichMarkdownImageResolverContext({ + filePath, + runtimeEnvironmentId, + settings, + worktreeId, + worktreeRoot + }) + ) } finally { isApplyingProgrammaticUpdateRef.current = false } From 04ec8e62761eed841b6dbfc14dee8d58193142f0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:15:38 -0700 Subject: [PATCH 06/41] Wire terminal-initiated worktree navigation to back-and-forth stack (#6243) * Unify terminal-driven worktree activation and Cmd+J recency marking. * Record worktree visits in the back/forward history stack unless navigating history. --- src/renderer/src/hooks/useIpcEvents.test.ts | 129 +++++++++++++++++++- src/renderer/src/hooks/useIpcEvents.ts | 31 +++-- 2 files changed, 140 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index a455cc712be..e5ec6a421f9 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1286,6 +1286,8 @@ describe('useIpcEvents updater integration', () => { const createTab = vi.fn(() => ({ id: 'tab-new' })) const setActiveView = vi.fn() const setActiveWorktree = vi.fn() + const markWorktreeVisited = vi.fn() + const recordWorktreeVisit = vi.fn() const setActiveTabType = vi.fn() const setActiveTab = vi.fn() const revealWorktreeInSidebar = vi.fn() @@ -1300,13 +1302,17 @@ describe('useIpcEvents updater integration', () => { const dispatchEvent = vi.fn() const createFloatingWorkspaceTerminalTab = vi.fn() const createWebRuntimeSessionTerminal = vi.fn().mockResolvedValue(false) + const focusRuntimeTerminalSurface = vi.fn(() => false) + const focusTerminalTabSurface = vi.fn() let floatingPanelFocused = false const storeState = { setUpdateStatus: vi.fn(), createTab, setActiveView, setActiveWorktree, - markWorktreeVisited: vi.fn(), + markWorktreeVisited, + recordWorktreeVisit, + isNavigatingHistory: false, setActiveTabType, setActiveTab, revealWorktreeInSidebar, @@ -1386,6 +1392,18 @@ describe('useIpcEvents updater integration', () => { }) => void) | null } = { current: null } + const focusTerminalListenerRef: { + current: + | ((data: { + tabId: string + worktreeId: string + leafId?: string | null + ackPaneKeyOnSuccess?: string + flashFocusedPane?: boolean + scrollToBottomIfOutputSinceLastView?: boolean + }) => void) + | null + } = { current: null } const newTerminalTabListenerRef: { current: (() => void) | null } = { current: null } vi.resetModules() @@ -1443,7 +1461,13 @@ describe('useIpcEvents updater integration', () => { isWebRuntimeSessionActive: vi.fn(() => false) })) vi.doMock('@/lib/focus-terminal-tab-surface', () => ({ - focusTerminalTabSurface: vi.fn() + focusTerminalTabSurface + })) + vi.doMock('@/runtime/sync-runtime-graph', () => ({ + focusRuntimeTerminalSurface + })) + vi.doMock('@/lib/activate-tab-and-focus-pane', () => ({ + activateTabAndFocusPane: vi.fn() })) vi.stubGlobal('window', { @@ -1514,7 +1538,19 @@ describe('useIpcEvents updater integration', () => { replyTerminalCreate, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, - onFocusTerminal: () => () => {}, + onFocusTerminal: ( + listener: (data: { + tabId: string + worktreeId: string + leafId?: string | null + ackPaneKeyOnSuccess?: string + flashFocusedPane?: boolean + scrollToBottomIfOutputSinceLastView?: boolean + }) => void + ) => { + focusTerminalListenerRef.current = listener + return () => {} + }, onFocusEditorTab: () => () => {}, onCloseSessionTab: () => () => {}, onMoveSessionTab: () => () => {}, @@ -1632,6 +1668,8 @@ describe('useIpcEvents updater integration', () => { expect(setActiveView).toHaveBeenCalledWith('terminal') expect(setActiveWorktree).toHaveBeenCalledWith('wt-2') + expect(markWorktreeVisited).toHaveBeenCalledWith('wt-2') + expect(recordWorktreeVisit).toHaveBeenCalledWith('wt-2') expect(createTab).toHaveBeenCalledWith('wt-2') expect(setActiveTabType).toHaveBeenCalledWith('terminal') expect(setActiveTab).toHaveBeenCalledWith('tab-new') @@ -1648,6 +1686,42 @@ describe('useIpcEvents updater integration', () => { createTab.mockClear() setActiveView.mockClear() setActiveWorktree.mockClear() + markWorktreeVisited.mockClear() + recordWorktreeVisit.mockClear() + setActiveTabType.mockClear() + setActiveTab.mockClear() + revealWorktreeInSidebar.mockClear() + setTabCustomTitle.mockClear() + queueTabStartupCommand.mockClear() + replyTerminalCreate.mockClear() + requestTerminalCreateListenerRef.current({ + requestId: 'req-focused', + worktreeId: 'wt-3', + title: 'Shell' + }) + + expect(setActiveView).toHaveBeenCalledWith('terminal') + expect(setActiveWorktree).toHaveBeenCalledWith('wt-3') + expect(markWorktreeVisited).toHaveBeenCalledWith('wt-3') + expect(recordWorktreeVisit).toHaveBeenCalledWith('wt-3') + expect(createTab).toHaveBeenCalledWith('wt-3', undefined, undefined, undefined) + expect(setActiveTabType).toHaveBeenCalledWith('terminal') + expect(setActiveTab).toHaveBeenCalledWith('tab-new') + expect(revealWorktreeInSidebar).toHaveBeenCalledWith('wt-3') + expect(setTabCustomTitle).toHaveBeenCalledWith('tab-new', 'Shell', { + recordInteraction: false + }) + expect(replyTerminalCreate).toHaveBeenCalledWith({ + requestId: 'req-focused', + tabId: 'tab-new', + title: 'Shell' + }) + + createTab.mockClear() + setActiveView.mockClear() + setActiveWorktree.mockClear() + markWorktreeVisited.mockClear() + recordWorktreeVisit.mockClear() setActiveTabType.mockClear() setActiveTab.mockClear() revealWorktreeInSidebar.mockClear() @@ -1673,6 +1747,8 @@ describe('useIpcEvents updater integration', () => { }) expect(setActiveView).not.toHaveBeenCalled() expect(setActiveWorktree).not.toHaveBeenCalled() + expect(markWorktreeVisited).not.toHaveBeenCalled() + expect(recordWorktreeVisit).not.toHaveBeenCalled() expect(setActiveTabType).not.toHaveBeenCalled() expect(setActiveTab).not.toHaveBeenCalled() expect(revealWorktreeInSidebar).not.toHaveBeenCalled() @@ -1699,6 +1775,53 @@ describe('useIpcEvents updater integration', () => { title: 'Codex' }) + if (typeof focusTerminalListenerRef.current !== 'function') { + throw new Error('Expected focus-terminal listener to be registered') + } + + setActiveView.mockClear() + setActiveWorktree.mockClear() + markWorktreeVisited.mockClear() + recordWorktreeVisit.mockClear() + setActiveTab.mockClear() + revealWorktreeInSidebar.mockClear() + focusRuntimeTerminalSurface.mockClear() + focusTerminalTabSurface.mockClear() + focusTerminalListenerRef.current({ + worktreeId: 'wt-4', + tabId: 'tab-focus', + leafId: 'leaf-focus' + }) + + expect(setActiveView).toHaveBeenCalledWith('terminal') + expect(setActiveWorktree).toHaveBeenCalledWith('wt-4') + expect(markWorktreeVisited).toHaveBeenCalledWith('wt-4') + expect(recordWorktreeVisit).toHaveBeenCalledWith('wt-4') + expect(setActiveTab).toHaveBeenCalledWith('tab-focus') + expect(revealWorktreeInSidebar).toHaveBeenCalledWith('wt-4') + expect(focusRuntimeTerminalSurface).toHaveBeenCalledWith('tab-focus', 'leaf-focus') + expect(focusTerminalTabSurface).toHaveBeenCalledWith('tab-focus', 'leaf-focus') + + storeState.isNavigatingHistory = true + setActiveView.mockClear() + setActiveWorktree.mockClear() + markWorktreeVisited.mockClear() + recordWorktreeVisit.mockClear() + setActiveTab.mockClear() + revealWorktreeInSidebar.mockClear() + focusTerminalListenerRef.current({ + worktreeId: 'wt-history', + tabId: 'tab-history' + }) + + expect(setActiveView).toHaveBeenCalledWith('terminal') + expect(setActiveWorktree).toHaveBeenCalledWith('wt-history') + expect(markWorktreeVisited).toHaveBeenCalledWith('wt-history') + expect(recordWorktreeVisit).not.toHaveBeenCalled() + expect(setActiveTab).toHaveBeenCalledWith('tab-history') + expect(revealWorktreeInSidebar).toHaveBeenCalledWith('wt-history') + storeState.isNavigatingHistory = false + createTab.mockClear() registerAgentLaunchConfig.mockClear() createTerminalListenerRef.current({ diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index d19cea07d4e..7474f9537cb 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -270,6 +270,17 @@ function resolveActiveBrowserPageId(state: AppState): string | null { return workspace.activePageId ?? workspace.pageIds?.[0] ?? pages[0]?.id ?? null } +function activateTerminalInitiatedWorktree(store: AppState, worktreeId: string): void { + store.setActiveView('terminal') + store.setActiveWorktree(worktreeId) + // Why: CLI/runtime terminal focus is user-visible worktree navigation, so it + // must feed both Cmd+J recency and the titlebar back/forward stack. + store.markWorktreeVisited(worktreeId) + if (!store.isNavigatingHistory) { + store.recordWorktreeVisit(worktreeId) + } +} + type TerminalSplitDirection = 'horizontal' | 'vertical' function insertLeafAfterSource( @@ -1358,13 +1369,7 @@ export function useIpcEvents(): void { const store = useAppStore.getState() const shouldActivate = activate !== false if (shouldActivate) { - store.setActiveView('terminal') - store.setActiveWorktree(worktreeId) - // Why: CLI-driven terminal focus is a user-initiated worktree switch - // and must stamp focus recency for Cmd+J. Doesn't route through - // activateAndRevealWorktree because it has custom terminal-creation - // logic; see docs/cmd-j-empty-query-ordering.md. - store.markWorktreeVisited(worktreeId) + activateTerminalInitiatedWorktree(store, worktreeId) } const existingTab = ptyId ? (store.tabsByWorktree[worktreeId] ?? []).find( @@ -1537,11 +1542,7 @@ export function useIpcEvents(): void { } const shouldActivate = data.activate !== false if (shouldActivate) { - store.setActiveView('terminal') - store.setActiveWorktree(worktreeId) - // Why: CLI-driven focused terminal-create requests are user-initiated - // worktree switches; unfocused renderer-backed creates must not reorder Cmd+J. - store.markWorktreeVisited(worktreeId) + activateTerminalInitiatedWorktree(store, worktreeId) } else { // Why: renderer-backed Codex startup must mount a TerminalPane so the // PTY is born in the renderer, but it must not switch the active UI. @@ -1650,11 +1651,7 @@ export function useIpcEvents(): void { scrollToBottomIfOutputSinceLastView }) => { const store = useAppStore.getState() - store.setActiveWorktree(worktreeId) - // Why: CLI-driven focus is a user-initiated switch; stamp focus - // recency for Cmd+J. See docs/cmd-j-empty-query-ordering.md. - store.markWorktreeVisited(worktreeId) - store.setActiveView('terminal') + activateTerminalInitiatedWorktree(store, worktreeId) store.setActiveTab(tabId) store.revealWorktreeInSidebar(worktreeId) if (ackPaneKeyOnSuccess || flashFocusedPane || scrollToBottomIfOutputSinceLastView) { From ab2db04c43ae5c0ba3afc8723e03655274a92fd5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:25:47 +0000 Subject: [PATCH 07/41] release: v1.4.97-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dd53f5545dc..6d9618f82a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.96", + "version": "1.4.97-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From ce85925c701e4281cb4af737716ef344d5f1d4a9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:31:28 -0700 Subject: [PATCH 08/41] Keep inline-code link labels linked in rich Markdown (#6224) --- .../editor/markdown-round-trip.test.ts | 59 +++++++++++++++++++ .../editor/rich-markdown-extensions.ts | 17 +++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index 551902aee27..a06bc883b55 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -20,6 +20,35 @@ function roundTripMarkdown(content: string): string { } } +function markdownAfterTextReplace(content: string, search: string, replacement: string): string { + const editor = new Editor({ + element: null, + extensions: createRichMarkdownExtensions(), + content: encodeRawMarkdownHtmlForRichEditor(content), + contentType: 'markdown' + }) + + try { + let from: number | null = null + editor.state.doc.descendants((node, pos) => { + if (from !== null || !node.isText || !node.text) { + return + } + const index = node.text.indexOf(search) + if (index !== -1) { + from = pos + index + } + }) + if (from === null) { + throw new Error(`Missing text: ${search}`) + } + editor.view.dispatch(editor.state.tr.insertText(replacement, from, from + search.length)) + return editor.getMarkdown().trimEnd() + } finally { + editor.destroy() + } +} + function slashCommandMarkdown(commandId: SlashCommandId): string { const editor = new Editor({ element: null, @@ -158,6 +187,36 @@ describe('rich markdown round trip', () => { ) }) + it('preserves links whose label is inline code', () => { + expect(roundTripMarkdown('Link to [`foo.md`](./foo.md) here\n')).toBe( + 'Link to [`foo.md`](./foo.md) here' + ) + }) + + it('preserves links whose label is inline code after an editor transaction', () => { + expect( + markdownAfterTextReplace('Link to [`foo.md`](./foo.md) here\n', 'here', 'here saved') + ).toBe('Link to [`foo.md`](./foo.md) here saved') + }) + + it('preserves links when editing inside an inline-code label', () => { + expect(markdownAfterTextReplace('Link to [`foo.md`](./foo.md) here\n', 'foo', 'bar')).toBe( + 'Link to [`bar.md`](./foo.md) here' + ) + }) + + it('preserves titled links whose label is inline code', () => { + expect(roundTripMarkdown('Link to [`foo.md`](./foo.md "Foo") here\n')).toBe( + 'Link to [`foo.md`](./foo.md "Foo") here' + ) + }) + + it('preserves bold link labels as formatted link text', () => { + expect(roundTripMarkdown('Link to [**bold**](./foo.md) here\n')).toBe( + 'Link to [**bold**](./foo.md) here' + ) + }) + it('does not surface Linear issue reference definitions as description text', () => { const input = [ '- [x] [H-279]', diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index c5d96c452e7..3b23a9df235 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -1,6 +1,7 @@ import type { AnyExtension } from '@tiptap/core' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' +import { Code } from '@tiptap/extension-code' import Image from '@tiptap/extension-image' import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight' import Placeholder from '@tiptap/extension-placeholder' @@ -28,6 +29,18 @@ import { createRichMarkdownAnnotationHighlightExtension } from './rich-markdown- const lowlight = createLowlight(common) +const RichMarkdownLink = Link.extend({ + // Why: link's priority must stay below code's default 100 so Markdown + // serializes code-styled labels as [`label`](href). + priority: 90 +}) + +const RichMarkdownCode = Code.extend({ + // Why: Markdown supports linked code labels, so code cannot exclude the link + // mark even though it should still stay exclusive with emphasis marks. + excludes: 'code bold italic strike underline' +}) + export function createRichMarkdownExtensions({ includePlaceholder = false }: { @@ -39,8 +52,10 @@ export function createRichMarkdownExtensions({ // preview and then still lose syntax on save. StarterKit.configure({ link: false, + code: false, codeBlock: false }), + RichMarkdownCode, CodeBlockLowlight.extend({ addNodeView() { return safeReactNodeViewRenderer(RichMarkdownCodeBlock) @@ -49,7 +64,7 @@ export function createRichMarkdownExtensions({ lowlight, defaultLanguage: null }), - Link.configure({ + RichMarkdownLink.configure({ openOnClick: false, autolink: true, linkOnPaste: true From 83f877ec352975884f1a843893ea58c1cc0cf254 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:47:40 -0700 Subject: [PATCH 09/41] Let terminal pane controls float over output (#6219) Co-authored-by: Orca --- src/renderer/src/assets/main.css | 16 +++---- src/renderer/src/assets/terminal.css | 46 ++++++++++++++++++- .../components/terminal-pane/TerminalPane.tsx | 18 +++----- .../TerminalPaneHeaderOverlay.test.tsx | 4 +- .../TerminalPaneHeaderOverlay.tsx | 4 +- ...ession-restored-banner-pane-state.test.tsx | 25 ++++++++-- .../session-restored-banner-pane-state.ts | 2 - 7 files changed, 85 insertions(+), 30 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 867ed6e958f..d2771ff0239 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -166,15 +166,15 @@ --terminal-pane-title-on-dark-fg: rgb(255 255 255 / 0.52); --terminal-pane-title-on-dark-input-fg: rgb(255 255 255 / 0.7); --terminal-pane-title-on-dark-placeholder: rgb(255 255 255 / 0.38); - --terminal-pane-title-on-dark-button-fg: rgb(255 255 255 / 0.3); - --terminal-pane-title-on-dark-button-hover-fg: rgb(255 255 255 / 0.8); + --terminal-pane-title-on-dark-button-fg: rgb(255 255 255); + --terminal-pane-title-on-dark-button-hover-fg: rgb(255 255 255); --terminal-pane-title-on-dark-input-bg: rgb(255 255 255 / 0.04); --terminal-pane-title-on-dark-separator: rgb(255 255 255 / 0.06); --terminal-pane-title-on-light-fg: rgb(24 24 27 / 0.64); --terminal-pane-title-on-light-input-fg: rgb(24 24 27 / 0.82); --terminal-pane-title-on-light-placeholder: rgb(24 24 27 / 0.48); - --terminal-pane-title-on-light-button-fg: rgb(24 24 27 / 0.42); - --terminal-pane-title-on-light-button-hover-fg: rgb(24 24 27 / 0.82); + --terminal-pane-title-on-light-button-fg: rgb(24 24 27); + --terminal-pane-title-on-light-button-hover-fg: rgb(24 24 27); --terminal-pane-title-on-light-input-bg: rgb(24 24 27 / 0.05); --terminal-pane-title-on-light-separator: rgb(24 24 27 / 0.1); --git-decoration-added: #587c0c; @@ -247,15 +247,15 @@ --terminal-pane-title-on-dark-fg: rgb(255 255 255 / 0.52); --terminal-pane-title-on-dark-input-fg: rgb(255 255 255 / 0.7); --terminal-pane-title-on-dark-placeholder: rgb(255 255 255 / 0.38); - --terminal-pane-title-on-dark-button-fg: rgb(255 255 255 / 0.3); - --terminal-pane-title-on-dark-button-hover-fg: rgb(255 255 255 / 0.8); + --terminal-pane-title-on-dark-button-fg: rgb(255 255 255); + --terminal-pane-title-on-dark-button-hover-fg: rgb(255 255 255); --terminal-pane-title-on-dark-input-bg: rgb(255 255 255 / 0.04); --terminal-pane-title-on-dark-separator: rgb(255 255 255 / 0.06); --terminal-pane-title-on-light-fg: rgb(24 24 27 / 0.64); --terminal-pane-title-on-light-input-fg: rgb(24 24 27 / 0.82); --terminal-pane-title-on-light-placeholder: rgb(24 24 27 / 0.48); - --terminal-pane-title-on-light-button-fg: rgb(24 24 27 / 0.42); - --terminal-pane-title-on-light-button-hover-fg: rgb(24 24 27 / 0.82); + --terminal-pane-title-on-light-button-fg: rgb(24 24 27); + --terminal-pane-title-on-light-button-hover-fg: rgb(24 24 27); --terminal-pane-title-on-light-input-bg: rgb(24 24 27 / 0.05); --terminal-pane-title-on-light-separator: rgb(24 24 27 / 0.1); --git-decoration-added: #81b88b; diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index c78145379ca..97d3cbb4744 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -352,7 +352,43 @@ .pane-title-bar[data-chromeless] { background: transparent; border-bottom: none; - padding: 0 4px; + padding: 0 2px; + pointer-events: none; +} + +.pane-title-bar[data-chromeless] .pane-title-actions { + pointer-events: auto; +} + +.pane-title-bar[data-chromeless] .pane-title-split-trigger, +.pane-title-bar[data-chromeless] .pane-title-close { + position: relative; + width: 18px; + min-width: 18px; + height: var(--orca-pane-title-height); + padding: 0; +} + +.pane-title-bar[data-chromeless] .pane-title-close { + margin-left: -1px; +} + +.pane-title-bar[data-chromeless] .pane-title-split-trigger::before, +.pane-title-bar[data-chromeless] .pane-title-close::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 23px; + height: 23px; + background: radial-gradient( + circle, + var(--orca-pane-title-bg, var(--background)) 0 44%, + color-mix(in srgb, var(--orca-pane-title-bg, var(--background)) 74%, transparent) 62%, + transparent 100% + ); + border-radius: 9999px; + transform: translate(-50%, -50%); } .pane-title-close { @@ -377,6 +413,14 @@ transition: opacity 120ms ease; } +.pane-title-bar[data-chromeless] .pane-title-split-trigger svg, +.pane-title-bar[data-chromeless] .pane-title-close svg { + /* Why: chromeless controls overlap row-one terminal text; the rounded backing + clears text inside open glyphs without adding one shared toolbar fill. */ + position: relative; + stroke-width: 2.75px; +} + .pane-title-bar[data-active-pane] .pane-title-split-trigger, .pane-title-bar[data-active-pane] .pane-title-close, .pane-title-bar:hover .pane-title-split-trigger, diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index bda9d525d18..67717080e98 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1763,27 +1763,24 @@ export default function TerminalPane({ return subscribeTerminalPaneAttention(tabId, applyTerminalPaneAttention) }, [tabId, paneCount, applyTerminalPaneAttention]) - // Sync the pane title reservation before paint. The title UI stays outside - // xterm's DOM, but xterm must still fit below it so the first terminal row is - // never hidden under the title strip. + // Sync the pane title reservation before paint. Text/banner chrome stays + // outside xterm's DOM, but xterm must still fit below it so the first + // terminal row is never hidden under meaningful status UI. useLayoutEffect(() => { const manager = managerRef.current if (!manager) { return } - // Show the title bar space when the pane has a title, is being - // inline-edited, or has transient startup chrome. Unread activity does - // not reserve this space; its overlay should not reflow terminal content. + // Show title space only for text/status chrome. Chromeless pane controls + // float over xterm so untitled panes keep their first row. const needsFit = syncSessionRestoredBannerTitleSpace({ panes: manager.getPanes(), paneTitles, renamingPaneId, - sessionRestoredBannerPaneIds, - reservePaneHeaderSpace: isActive && (isVisible || shouldMeasureHiddenStartup) + sessionRestoredBannerPaneIds }) if (needsFit && (isVisible || shouldMeasureHiddenStartup)) { - // Why: inactive terminal tabs can drop active-only header reservation. - // Fitting that hidden geometry changes PTY rows and wakes TUIs with + // Why: fitting hidden geometry changes PTY rows and wakes TUIs with // SIGWINCH; the visible resume path owns any real layout correction. fitPanes(manager) } @@ -1793,7 +1790,6 @@ export default function TerminalPane({ paneTitles, renamingPaneId, sessionRestoredBannerPaneIds, - isActive, isVisible, shouldMeasureHiddenStartup ]) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx index 6178db32516..78e80b3231e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx @@ -127,11 +127,13 @@ describe('TerminalPaneHeaderOverlay', () => { expect(onClosePane).not.toHaveBeenCalledWith(1) }) - it('keeps close-pane available for untitled split pane headers', () => { + it('keeps split and close-pane controls available for untitled split pane headers', () => { const { container, onClosePane, onRemoveTitle } = renderOverlay({ paneTitles: { 1: '', 2: '' } }) + expect(container.querySelector('button[aria-label="Split Terminal Right"]')).not.toBeNull() + expect(container.querySelector('.pane-title-drag-handle')).toBeNull() const closePane = container.querySelector('button[aria-label="Close Pane"]') expect(closePane).not.toBeNull() diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index ca10b1ac38b..2aa1d729745 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -182,7 +182,7 @@ export default function TerminalPaneHeaderOverlay({ /> ) : ( <> - {paneCount > 1 && ( + {paneCount > 1 && !isChromeless && (