diff --git a/apps/desktop/src/main/runtime.ts b/apps/desktop/src/main/runtime.ts index 6ef977656..d72657171 100644 --- a/apps/desktop/src/main/runtime.ts +++ b/apps/desktop/src/main/runtime.ts @@ -1422,6 +1422,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom ...MAC_WINDOW_CHROME, webPreferences: { additionalArguments: osLocaleAdditionalArguments(options.osLocale), + backgroundThrottling: false, contextIsolation: true, nodeIntegration: false, preload: preloadPath, diff --git a/apps/desktop/tests/main/window-chrome.test.ts b/apps/desktop/tests/main/window-chrome.test.ts index d4099c13a..01d57f706 100644 --- a/apps/desktop/tests/main/window-chrome.test.ts +++ b/apps/desktop/tests/main/window-chrome.test.ts @@ -17,4 +17,10 @@ describe("desktop BrowserWindow chrome options", () => { expect(runtimeSource).toContain("flex: 0 0 96px !important;"); expect(runtimeSource).toContain("width: 96px !important;"); }); + + test("keeps the visible renderer responsive when Chromium misclassifies visibility", () => { + const browserWindowBlock = /new BrowserWindow\(\{([\s\S]*?)title: "Open Design",([\s\S]*?)width: 1280,/.exec(runtimeSource)?.[0] ?? ""; + + expect(browserWindowBlock).toContain("backgroundThrottling: false"); + }); }); diff --git a/apps/web/src/components/ChatPane.tsx b/apps/web/src/components/ChatPane.tsx index 10c0c25d2..19870ace7 100644 --- a/apps/web/src/components/ChatPane.tsx +++ b/apps/web/src/components/ChatPane.tsx @@ -4,13 +4,16 @@ import { useCallback, useDeferredValue, useEffect, + useLayoutEffect, useMemo, useRef, useState, + type CSSProperties, type DragEvent as ReactDragEvent, type MutableRefObject, type ReactNode, } from 'react'; +import { createPortal } from 'react-dom'; import { useAnalytics } from '../analytics/provider'; import { trackChatPanelClick, trackRunFailedToastSurfaceView } from '../analytics/events'; import { attributedAmrUrl, recordAmrEntry } from '../analytics/amr-attribution'; @@ -512,6 +515,8 @@ export function ChatPane({ const chatLogScrollIdleTimerRef = useRef(null); const historyWrapRef = useRef(null); const composerRef = useRef(null); + const composerSlotRef = useRef(null); + const composerLayerRef = useRef(null); const pinnedTodoRef = useRef(null); const queuedSendStripRef = useRef(null); const didInitialScrollRef = useRef(false); @@ -564,6 +569,13 @@ export function ChatPane({ const [scrolledFromBottom, setScrolledFromBottom] = useState(false); const [chatLogScrollable, setChatLogScrollable] = useState(false); const [chatLogScrolling, setChatLogScrolling] = useState(false); + const [composerPortalTarget, setComposerPortalTarget] = useState(null); + const [composerPortalRect, setComposerPortalRect] = useState<{ + left: number; + width: number; + bottom: number; + } | null>(null); + const [composerSlotHeight, setComposerSlotHeight] = useState(0); // The user can dismiss the pinned task list once everything is complete. // We key the dismissal on the snapshot (serialized TodoWrite input) so // the next time the agent emits a different snapshot the card returns, @@ -1199,6 +1211,169 @@ export function ChatPane({ el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); } + useEffect(() => { + if (typeof document === 'undefined') return; + setComposerPortalTarget(document.body); + }, []); + + useLayoutEffect(() => { + if (tab !== 'chat') { + setComposerPortalRect(null); + return; + } + const slot = composerSlotRef.current; + if (!slot || typeof window === 'undefined') return; + + let frame: number | null = null; + const updateRect = () => { + frame = null; + const rect = slot.getBoundingClientRect(); + setComposerPortalRect((prev) => { + const next = { + left: Math.round(rect.left), + width: Math.round(rect.width), + bottom: Math.max(0, Math.round(window.innerHeight - rect.bottom)), + }; + if ( + prev + && prev.left === next.left + && prev.width === next.width + && prev.bottom === next.bottom + ) { + return prev; + } + return next; + }); + }; + const scheduleUpdate = () => { + if (frame !== null) return; + frame = window.requestAnimationFrame(updateRect); + }; + + updateRect(); + const resizeObserver = + typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(scheduleUpdate) + : null; + resizeObserver?.observe(slot); + const pane = slot.closest('.pane'); + if (pane) resizeObserver?.observe(pane); + window.addEventListener('resize', scheduleUpdate); + window.visualViewport?.addEventListener('resize', scheduleUpdate); + + return () => { + if (frame !== null) window.cancelAnimationFrame(frame); + resizeObserver?.disconnect(); + window.removeEventListener('resize', scheduleUpdate); + window.visualViewport?.removeEventListener('resize', scheduleUpdate); + }; + }, [tab]); + + useLayoutEffect(() => { + if (tab !== 'chat' || !composerPortalTarget || !composerPortalRect) return; + const layer = composerLayerRef.current; + if (!layer || typeof window === 'undefined') return; + + let frame: number | null = null; + const updateHeight = () => { + frame = null; + const nextHeight = Math.ceil(layer.getBoundingClientRect().height); + setComposerSlotHeight((prev) => (prev === nextHeight ? prev : nextHeight)); + }; + const scheduleUpdate = () => { + if (frame !== null) return; + frame = window.requestAnimationFrame(updateHeight); + }; + + updateHeight(); + const resizeObserver = + typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(scheduleUpdate) + : null; + resizeObserver?.observe(layer); + + return () => { + if (frame !== null) window.cancelAnimationFrame(frame); + resizeObserver?.disconnect(); + }; + }, [composerPortalRect, composerPortalTarget, tab]); + + const composerNode = ( + { + pinnedToBottomRef.current = true; + scrolledToFormRef.current = new Set(); + if (editingQueuedSendId && onUpdateQueuedSend) { + const original = queuedItems.find((item) => item.id === editingQueuedSendId); + const update: QueuedSendUpdate = { + prompt, + attachments, + commentAttachments, + }; + const nextMeta = meta ?? original?.meta; + if (nextMeta !== undefined) update.meta = nextMeta; + onUpdateQueuedSend(editingQueuedSendId, update); + setEditingQueuedSendId(null); + return; + } + // Arm "anchor to top": the messages effect promotes this once + // the new user turn renders, pinning it to the top of the view. + anchorPendingRef.current = true; + onSend(prompt, attachments, commentAttachments, meta); + }} + onStop={onStop} + onOpenSettings={onOpenSettings} + onOpenMcpSettings={onOpenMcpSettings} + petConfig={petConfig} + onAdoptPet={onAdoptPet} + onTogglePet={onTogglePet} + onOpenPetSettings={onOpenPetSettings} + researchAvailable={researchAvailable} + projectMetadata={projectMetadata} + onProjectMetadataChange={onProjectMetadataChange} + activeWorkspaceContext={activeWorkspaceContext} + workspaceContexts={workspaceContexts} + byokApiProtocol={byokApiProtocol} + byokImageModel={byokImageModel} + onChangeByokImageModel={onChangeByokImageModel} + byokVideoModel={byokVideoModel} + onChangeByokVideoModel={onChangeByokVideoModel} + byokSpeechModel={byokSpeechModel} + onChangeByokSpeechModel={onChangeByokSpeechModel} + byokSpeechVoice={byokSpeechVoice} + onChangeByokSpeechVoice={onChangeByokSpeechVoice} + currentSkillId={currentSkillId} + onProjectSkillChange={onProjectSkillChange} + pinnedPluginId={activePluginSnapshot?.pluginId ?? null} + footerAccessory={composerFooterAccessory} + currentDesignSystemId={currentDesignSystemId} + onActiveDesignSystemChange={onActiveDesignSystemChange} + onShowToast={onShowToast} + /> + ); + const shouldPortalComposer = + tab === 'chat' + && composerPortalTarget !== null + && composerPortalRect !== null + && composerPortalRect.width > 0; + const composerSlotStyle: CSSProperties | undefined = shouldPortalComposer + ? { minHeight: composerSlotHeight > 0 ? composerSlotHeight : undefined } + : undefined; + return (
@@ -1612,71 +1787,30 @@ export function ChatPane({ onReorder={onReorderQueuedSends} onSendNow={onSendQueuedNow} /> - { - pinnedToBottomRef.current = true; - scrolledToFormRef.current = new Set(); - if (editingQueuedSendId && onUpdateQueuedSend) { - const original = queuedItems.find((item) => item.id === editingQueuedSendId); - const update: QueuedSendUpdate = { - prompt, - attachments, - commentAttachments, - }; - const nextMeta = meta ?? original?.meta; - if (nextMeta !== undefined) update.meta = nextMeta; - onUpdateQueuedSend(editingQueuedSendId, update); - setEditingQueuedSendId(null); - return; - } - // Arm "anchor to top": the messages effect promotes this once - // the new user turn renders, pinning it to the top of the view. - anchorPendingRef.current = true; - onSend(prompt, attachments, commentAttachments, meta); - }} - onStop={onStop} - onOpenSettings={onOpenSettings} - onOpenMcpSettings={onOpenMcpSettings} - petConfig={petConfig} - onAdoptPet={onAdoptPet} - onTogglePet={onTogglePet} - onOpenPetSettings={onOpenPetSettings} - researchAvailable={researchAvailable} - projectMetadata={projectMetadata} - onProjectMetadataChange={onProjectMetadataChange} - activeWorkspaceContext={activeWorkspaceContext} - workspaceContexts={workspaceContexts} - byokApiProtocol={byokApiProtocol} - byokImageModel={byokImageModel} - onChangeByokImageModel={onChangeByokImageModel} - byokVideoModel={byokVideoModel} - onChangeByokVideoModel={onChangeByokVideoModel} - byokSpeechModel={byokSpeechModel} - onChangeByokSpeechModel={onChangeByokSpeechModel} - byokSpeechVoice={byokSpeechVoice} - onChangeByokSpeechVoice={onChangeByokSpeechVoice} - currentSkillId={currentSkillId} - onProjectSkillChange={onProjectSkillChange} - pinnedPluginId={activePluginSnapshot?.pluginId ?? null} - footerAccessory={composerFooterAccessory} - currentDesignSystemId={currentDesignSystemId} - onActiveDesignSystemChange={onActiveDesignSystemChange} - onShowToast={onShowToast} - /> +
+ {shouldPortalComposer ? null : composerNode} +
+ {shouldPortalComposer && composerPortalTarget && composerPortalRect + ? createPortal( +
+ {composerNode} +
, + composerPortalTarget, + ) + : null} ) : null}
diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index 395380418..431831e17 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -2311,11 +2311,7 @@ export function ProjectView({ const attachRecoverableRuns = async () => { const missingRunIdMessages = messages.filter((m) => { if (m.role !== 'assistant' || m.runId) return false; - const producedFileCount = Array.isArray(m.producedFiles) ? m.producedFiles.length : 0; - return ( - isActiveRunStatus(m.runStatus) || - (m.runStatus === 'succeeded' && (!m.content.trim() || producedFileCount === 0)) - ); + return isActiveRunStatus(m.runStatus); }); const activeRuns = missingRunIdMessages.length > 0 ? await listActiveChatRuns(project.id, reattachConversationId) @@ -2340,14 +2336,8 @@ export function ProjectView({ for (const message of messages) { if (cancelled) return; if (message.role !== 'assistant') continue; - const producedFileCount = Array.isArray(message.producedFiles) - ? message.producedFiles.length - : 0; - const needsTerminalReplay = - message.runStatus === 'succeeded' && - (!message.content.trim() || producedFileCount === 0); - const needsFullReplay = needsTerminalReplay || isActiveRunStatus(message.runStatus); - if (!isActiveRunStatus(message.runStatus) && !needsTerminalReplay) continue; + const needsFullReplay = isActiveRunStatus(message.runStatus); + if (!needsFullReplay) continue; const fallbackRun = !message.runId ? activeByMessage.get(message.id) ?? historicalByMessage.get(message.id) ?? null : null; diff --git a/apps/web/src/runtime/generation-preview.ts b/apps/web/src/runtime/generation-preview.ts index 1f25e5078..6bed22942 100644 --- a/apps/web/src/runtime/generation-preview.ts +++ b/apps/web/src/runtime/generation-preview.ts @@ -161,10 +161,15 @@ export function buildGenerationPreviewState(input: { // Once the user has something previewable, only the error state takes // over the surface; the calmer waiting states defer to the live preview // so we never hide a finished artifact behind a status card. - if (hasPreviewSurface && phase !== 'failed') return null; + const events = latestAssistant.events ?? []; + if (hasPreviewSurface) { + if (phase !== 'failed') return null; + const hasExplicitFailure = + Boolean(input.conversationError?.trim()) || eventsHaveStatus(events, ['error']); + if (!hasExplicitFailure) return null; + } const failed = phase === 'failed'; - const events = latestAssistant.events ?? []; const derived = deriveGenerationPreviewModel({ events, hasArtifactHtml: Boolean(input.artifactHtml?.trim()), diff --git a/apps/web/src/styles/chat.css b/apps/web/src/styles/chat.css index 458c504f1..408688fe3 100644 --- a/apps/web/src/styles/chat.css +++ b/apps/web/src/styles/chat.css @@ -874,6 +874,19 @@ .composer:has(.composer-import-menu) { z-index: 80; } +.chat-composer-slot { + flex: 0 0 auto; +} +.chat-composer-fixed-layer { + position: fixed; + z-index: 45; + pointer-events: none; + contain: layout style paint; + transform: translateZ(0); +} +.chat-composer-fixed-layer .composer { + pointer-events: auto; +} /* Elevation kicks in only when a pinned todo card is sitting directly * above the composer. Without the card the chat scroll already provides * its own visual edge; the shadow would just darken empty whitespace. */ diff --git a/apps/web/src/styles/viewer/routines.css b/apps/web/src/styles/viewer/routines.css index f07776353..33cf088ab 100644 --- a/apps/web/src/styles/viewer/routines.css +++ b/apps/web/src/styles/viewer/routines.css @@ -1344,6 +1344,11 @@ padding: 9px 10px 11px; background: transparent; } +.chat-composer-fixed-layer .composer { + gap: 5px; + padding: 8px 10px 10px; + background: transparent; +} .app .composer-shell { padding: 7px; border-color: color-mix(in srgb, var(--border) 84%, var(--border-strong)); @@ -1351,16 +1356,30 @@ background: color-mix(in srgb, var(--bg-panel) 96%, var(--bg-subtle)); box-shadow: var(--shadow-sm); } +.chat-composer-fixed-layer .composer-shell { + padding: 0; + border-color: var(--border); + border-radius: 10px; + box-shadow: none; +} .app .composer-shell:focus-within { border-color: color-mix(in srgb, var(--accent) 34%, var(--border-strong)); box-shadow: 0 1px 2px color-mix(in srgb, var(--text) 6%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent) 6%, transparent); } +.chat-composer-fixed-layer .composer-shell:focus-within { + border-color: var(--border-strong); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--text) 4%, transparent); +} .app .composer.drag-active .composer-shell { border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 9%, transparent); } +.chat-composer-fixed-layer .composer.drag-active .composer-shell { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 9%, transparent); +} .app .composer textarea { min-height: 56px; max-height: min(184px, 34vh); @@ -1368,22 +1387,40 @@ mention-highlight overlay share one value. Re-pinning it here would only set the textarea, drifting the overlay glyphs off the caret. */ } +.chat-composer-fixed-layer .composer textarea { + min-height: 56px; + max-height: min(184px, 34vh); +} .app .composer-row { min-height: 30px; padding-top: 6px; margin-top: 1px; gap: 6px; } +.chat-composer-fixed-layer .composer-row { + min-height: 32px; + padding-top: 2px; +} .app .composer-row .icon-btn, .app .composer-import, .app .composer-research, .app .composer-send { border-radius: 7px; } +.chat-composer-fixed-layer .composer-row .icon-btn, +.chat-composer-fixed-layer .composer-import, +.chat-composer-fixed-layer .composer-research, +.chat-composer-fixed-layer .composer-send { + border-radius: 9px; +} .app .composer-row .icon-btn { width: 28px; height: 28px; } +.chat-composer-fixed-layer .composer-row .icon-btn { + width: 28px; + height: 28px; +} .app .composer-send { height: 28px; min-height: 28px; @@ -1393,12 +1430,26 @@ font-weight: 600; box-shadow: none; } +.chat-composer-fixed-layer .composer-send { + height: 30px; + min-height: 30px; + padding: 0 12px; + font-size: 12.5px; + font-weight: 600; + box-shadow: none; +} .app .composer-send:disabled { background: var(--bg-subtle); border-color: var(--border); color: var(--text-faint); cursor: default; } +.chat-composer-fixed-layer .composer-send:disabled { + background: var(--bg-subtle); + border-color: var(--border); + color: var(--text-faint); + cursor: default; +} .app .working-dir-pill { position: relative; display: inline-flex; @@ -1522,6 +1573,14 @@ text-overflow: clip; white-space: normal; } +.chat-composer-fixed-layer .composer-hint { + order: -1; + margin: 0 8px 1px; + color: color-mix(in srgb, var(--text-muted) 64%, transparent); + overflow: visible; + text-overflow: clip; + white-space: normal; +} .app .workspace { padding: 0; diff --git a/apps/web/src/utils/inlineMentions.ts b/apps/web/src/utils/inlineMentions.ts index 87c763dcb..e3d8e2b68 100644 --- a/apps/web/src/utils/inlineMentions.ts +++ b/apps/web/src/utils/inlineMentions.ts @@ -38,23 +38,34 @@ export function buildInlineMentionParts( if (!text) return null; if (!text.includes('@')) return null; const highlightUnknown = options.highlightUnknown ?? true; - const known = normalizeEntities(entities); + const known = getMentionTokenIndex(entities); const parts: InlineMentionPart[] = []; - let index = 0; + let scanStart = 0; + let copiedUntil = 0; let found = false; - while (index < text.length) { - const knownMatch = findNextKnownMention(text, known, index); - const unknownMatch = highlightUnknown ? findNextUnknownMention(text, index) : null; - const match = pickEarlierMention(knownMatch, unknownMatch); - - if (!match) { - parts.push({ kind: 'text', text: text.slice(index) }); - break; + while (scanStart < text.length) { + const start = text.indexOf('@', scanStart); + if (start === -1) break; + if (!isMentionBoundary(text, start)) { + scanStart = start + 1; + continue; } - if (match.start > index) { - parts.push({ kind: 'text', text: text.slice(index, match.start) }); + const knownMatch = findKnownMentionAt(text, known, start); + const unknownMatch = highlightUnknown ? findUnknownMentionAt(text, start) : null; + const match = + knownMatch && (!unknownMatch || knownMatch.token.length >= unknownMatch.token.length) + ? knownMatch + : unknownMatch; + + if (!match) { + scanStart = start + 1; + continue; + } + + if (match.start > copiedUntil) { + parts.push({ kind: 'text', text: text.slice(copiedUntil, match.start) }); } parts.push({ kind: 'mention', @@ -62,26 +73,45 @@ export function buildInlineMentionParts( text: match.token, }); found = true; - index = match.start + match.token.length; + copiedUntil = match.start + match.token.length; + scanStart = copiedUntil; + } + + if (copiedUntil < text.length) { + parts.push({ kind: 'text', text: text.slice(copiedUntil) }); } return found ? coalesceTextParts(parts) : null; } -// Cache the normalized+sorted list keyed by the input array's identity. The -// composer feeds the SAME memoized `knownEntities` array on every keystroke, so -// without this the full map/filter/sort re-ran per character (and per render -// for the highlight path). A WeakMap lets the entry GC when the array changes. -const normalizedEntitiesCache = new WeakMap(); +interface MentionTrieNode { + children: Map; + entity?: InlineMentionEntity; + token?: string; +} -function normalizeEntities(entities: InlineMentionEntity[]): InlineMentionEntity[] { - const cached = normalizedEntitiesCache.get(entities); +interface MentionTokenIndex { + root: MentionTrieNode; +} + +const mentionTokenIndexCache = new WeakMap(); + +function getMentionTokenIndex(entities: InlineMentionEntity[]): MentionTokenIndex { + const cached = mentionTokenIndexCache.get(entities); if (cached) return cached; + + const root: MentionTrieNode = { children: new Map() }; const seen = new Set(); const normalized = entities .map((entity) => { const token = entity.token ?? inlineMentionToken(entity.label); - return { ...entity, token }; + return { + id: entity.id, + kind: entity.kind, + label: entity.label, + token, + ...(entity.title ? { title: entity.title } : {}), + }; }) .filter((entity) => { if (!entity.token || entity.token === '@') return false; @@ -91,67 +121,65 @@ function normalizeEntities(entities: InlineMentionEntity[]): InlineMentionEntity return true; }) .sort((a, b) => (b.token?.length ?? 0) - (a.token?.length ?? 0)); - normalizedEntitiesCache.set(entities, normalized); - return normalized; -} -function findNextKnownMention( - text: string, - entities: InlineMentionEntity[], - from: number, -): MentionMatch | null { - let best: MentionMatch | null = null; - for (const entity of entities) { + for (const entity of normalized) { const token = entity.token; if (!token) continue; - let start = text.indexOf(token, from); - while (start !== -1 && !isMentionBoundary(text, start)) { - start = text.indexOf(token, start + 1); + let node = root; + for (const char of token) { + let child = node.children.get(char); + if (!child) { + child = { children: new Map() }; + node.children.set(char, child); + } + node = child; } - if (start === -1) continue; - if ( - !best || - start < best.start || - (start === best.start && token.length > best.token.length) - ) { - best = { start, token, entity }; + if (!node.entity) { + node.entity = entity; + node.token = token; + } + } + + const index = { root }; + mentionTokenIndexCache.set(entities, index); + return index; +} + +function findKnownMentionAt( + text: string, + index: MentionTokenIndex, + start: number, +): MentionMatch | null { + let best: MentionMatch | null = null; + let node: MentionTrieNode | undefined = index.root; + for (let i = start; i < text.length; i += 1) { + node = node.children.get(text[i] ?? ''); + if (!node) break; + if (node.entity && node.token && isMentionRightBoundary(text, i + 1)) { + best = { start, token: node.token, entity: node.entity }; } } return best; } -function findNextUnknownMention(text: string, from: number): MentionMatch | null { - const mentionPattern = /@[^\s@]+/g; - mentionPattern.lastIndex = from; - let match: RegExpExecArray | null; - while ((match = mentionPattern.exec(text)) !== null) { - const token = match[0]; - const start = match.index; - if (!isMentionBoundary(text, start)) continue; - return { - start, - token, - entity: { - id: `unknown:${token}`, - kind: 'unknown', - label: token.slice(1), - token, - title: token, - }, - }; +function findUnknownMentionAt(text: string, start: number): MentionMatch | null { + let end = start + 1; + if (end >= text.length || /[\s@]/.test(text[end] ?? '')) return null; + while (end < text.length && !/[\s@]/.test(text[end] ?? '')) { + end += 1; } - return null; -} - -function pickEarlierMention( - known: MentionMatch | null, - unknown: MentionMatch | null, -): MentionMatch | null { - if (!known) return unknown; - if (!unknown) return known; - if (known.start < unknown.start) return known; - if (unknown.start < known.start) return unknown; - return known.token.length >= unknown.token.length ? known : unknown; + const token = text.slice(start, end); + return { + start, + token, + entity: { + id: `unknown:${token}`, + kind: 'unknown', + label: token.slice(1), + token, + title: token, + }, + }; } /** diff --git a/apps/web/tests/components/ChatPane.streaming.test.tsx b/apps/web/tests/components/ChatPane.streaming.test.tsx index 8d336e611..37dacfaf9 100644 --- a/apps/web/tests/components/ChatPane.streaming.test.tsx +++ b/apps/web/tests/components/ChatPane.streaming.test.tsx @@ -107,6 +107,12 @@ class MockResizeObserver { trigger(target: Element) { this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver); } + + static triggerObserved(target: Element) { + for (const instance of MockResizeObserver.instances) { + if (instance.observed.has(target)) instance.trigger(target); + } + } } function mockDataTransfer(): DataTransfer { @@ -777,7 +783,7 @@ Expected output: }, }); - MockResizeObserver.instances[0]?.trigger(strip); + MockResizeObserver.triggerObserved(strip); expect(log!.scrollTop).toBe(600); }); diff --git a/apps/web/tests/components/ProjectView.reattach-restore.test.tsx b/apps/web/tests/components/ProjectView.reattach-restore.test.tsx index 13e7af399..bcc3303c8 100644 --- a/apps/web/tests/components/ProjectView.reattach-restore.test.tsx +++ b/apps/web/tests/components/ProjectView.reattach-restore.test.tsx @@ -21,6 +21,7 @@ const fetchDesignSystem = vi.fn(); const getTemplate = vi.fn(); const fetchChatRunStatus = vi.fn(); const listActiveChatRuns = vi.fn(); +const listProjectRuns = vi.fn(); const reattachDaemonRun = vi.fn(); const streamViaDaemon = vi.fn(); const saveMessage = vi.fn(); @@ -47,6 +48,7 @@ vi.mock('../../src/providers/anthropic', () => ({ vi.mock('../../src/providers/daemon', () => ({ fetchChatRunStatus: (...args: unknown[]) => fetchChatRunStatus(...args), listActiveChatRuns: (...args: unknown[]) => listActiveChatRuns(...args), + listProjectRuns: (...args: unknown[]) => listProjectRuns(...args), reattachDaemonRun: (...args: unknown[]) => reattachDaemonRun(...args), streamViaDaemon: (...args: unknown[]) => streamViaDaemon(...args), })); @@ -205,6 +207,42 @@ describe('ProjectView daemon reattach restore', () => { window.sessionStorage.clear(); }); + it('does not replay a terminal succeeded row just because produced files are missing', async () => { + const startedAt = Date.now(); + listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]); + listMessages.mockResolvedValue([ + { + id: 'msg-done', + role: 'assistant', + content: 'All done!', + createdAt: startedAt, + startedAt, + runStatus: 'succeeded', + } satisfies ChatMessage, + ]); + fetchPreviewComments.mockResolvedValue([]); + loadTabs.mockResolvedValue({ tabs: [], activeTabId: null }); + fetchProjectFiles.mockResolvedValue([]); + fetchLiveArtifacts.mockResolvedValue([]); + fetchSkill.mockResolvedValue(null); + fetchDesignSystem.mockResolvedValue(null); + getTemplate.mockResolvedValue(null); + + renderProjectView(); + + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(fetchProjectFiles).toHaveBeenCalled()); + expect(listActiveChatRuns).not.toHaveBeenCalled(); + expect(listProjectRuns).not.toHaveBeenCalled(); + expect(fetchChatRunStatus).not.toHaveBeenCalled(); + expect(reattachDaemonRun).not.toHaveBeenCalled(); + expect( + saveMessage.mock.calls + .map((call) => call[2] as ChatMessage) + .some((m) => m?.id === 'msg-done' && m.runStatus === 'failed'), + ).toBe(false); + }); + it('populates producedFiles on the persisted message after reattach completes', async () => { const startedAt = Date.now(); listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]); diff --git a/apps/web/tests/components/ProjectView.run-cleanup.test.tsx b/apps/web/tests/components/ProjectView.run-cleanup.test.tsx index ec13b8c97..fa5424f14 100644 --- a/apps/web/tests/components/ProjectView.run-cleanup.test.tsx +++ b/apps/web/tests/components/ProjectView.run-cleanup.test.tsx @@ -1242,7 +1242,7 @@ describe('ProjectView daemon cleanup', () => { }); }); - it('relinks terminal replay to an existing artifact without writing a duplicate file', async () => { + it('does not replay a terminal succeeded row with empty produced files', async () => { const runCreatedAt = Date.now(); const existingArtifact = { artifactManifest: { @@ -1329,20 +1329,17 @@ describe('ProjectView daemon cleanup', () => { />, ); - await waitFor(() => { - expect(saveMessage.mock.calls).toEqual( - expect.arrayContaining([ - expect.arrayContaining([ - 'project-1', - 'conv-1', - expect.objectContaining({ - id: 'msg-replay', - producedFiles: [existingArtifact], - }), - ]), - ]), - ); - }); + await waitFor(() => expect(fetchProjectFiles).toHaveBeenCalledWith('project-1')); + expect(fetchChatRunStatus).not.toHaveBeenCalled(); + expect(reattachDaemonRun).not.toHaveBeenCalled(); + expect(saveMessage).not.toHaveBeenCalledWith( + 'project-1', + 'conv-1', + expect.objectContaining({ + id: 'msg-replay', + producedFiles: [existingArtifact], + }), + ); expect(writeProjectTextFile).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/tests/runtime/generation-preview.test.ts b/apps/web/tests/runtime/generation-preview.test.ts index 330848343..457a4c41b 100644 --- a/apps/web/tests/runtime/generation-preview.test.ts +++ b/apps/web/tests/runtime/generation-preview.test.ts @@ -263,6 +263,49 @@ describe('generation preview helpers', () => { ).toBeNull(); }); + it('does not cover an available preview for a stale failed row without an error event', () => { + const assistant: ChatMessage = { + id: 'a1', + role: 'assistant', + content: 'All done!', + runStatus: 'failed', + startedAt: Date.now() - 4_000, + events: [{ kind: 'text', text: 'All done!' }], + }; + expect( + buildGenerationPreviewState({ + designSystemProject: false, + messages: [assistant], + streaming: false, + activeTab: 'index.html', + projectFiles: [{ name: 'index.html', size: 1, mtime: 1, kind: 'html', mime: 'text/html' }], + liveArtifacts: [], + }), + ).toBeNull(); + }); + + it('keeps an explicit failed state over a preview when the run has an error event', () => { + const assistant: ChatMessage = { + id: 'a1', + role: 'assistant', + content: '', + runStatus: 'failed', + startedAt: Date.now() - 4_000, + events: [{ kind: 'status', label: 'error', detail: 'Generation failed', code: 'UNKNOWN' }], + }; + const state = buildGenerationPreviewState({ + designSystemProject: false, + messages: [assistant], + streaming: false, + activeTab: 'index.html', + projectFiles: [{ name: 'index.html', size: 1, mtime: 1, kind: 'html', mime: 'text/html' }], + liveArtifacts: [], + }); + expect(state?.phase).toBe('failed'); + expect(state?.errorMessage).toBe('Generation failed'); + expect(state?.retryTarget).toBe(assistant); + }); + it('builds a failed state with a retry target', () => { const assistant: ChatMessage = { id: 'a1', diff --git a/apps/web/tests/utils/inlineMentions.test.ts b/apps/web/tests/utils/inlineMentions.test.ts index 3a4ea341f..80dc1dcc9 100644 --- a/apps/web/tests/utils/inlineMentions.test.ts +++ b/apps/web/tests/utils/inlineMentions.test.ts @@ -46,4 +46,66 @@ describe('buildInlineMentionParts', () => { }, ]); }); + + it('reuses the normalized mention index across draft updates', () => { + let tokenReads = 0; + const entities: InlineMentionEntity[] = Array.from({ length: 1_000 }, (_, index) => ({ + id: `file-${index}`, + kind: 'file', + label: `file-${index}.html`, + get token() { + tokenReads += 1; + return `@file-${index}.html`; + }, + })); + + expect(buildInlineMentionParts('@missing-one', entities)).toEqual([ + { + kind: 'mention', + text: '@missing-one', + entity: { + id: 'unknown:@missing-one', + kind: 'unknown', + label: 'missing-one', + token: '@missing-one', + title: '@missing-one', + }, + }, + ]); + expect(buildInlineMentionParts('@missing-two', entities)).toEqual([ + { + kind: 'mention', + text: '@missing-two', + entity: { + id: 'unknown:@missing-two', + kind: 'unknown', + label: 'missing-two', + token: '@missing-two', + title: '@missing-two', + }, + }, + ]); + expect(tokenReads).toBe(entities.length); + }); + + it('preserves longest known mentions that contain spaces', () => { + const parts = buildInlineMentionParts('Open @docs/read me.md now', [ + { id: 'docs/read me.md', kind: 'file', label: 'docs/read me.md' }, + ]); + + expect(parts).toEqual([ + { kind: 'text', text: 'Open ' }, + { + kind: 'mention', + text: '@docs/read me.md', + entity: { + id: 'docs/read me.md', + kind: 'file', + label: 'docs/read me.md', + token: '@docs/read me.md', + }, + }, + { kind: 'text', text: ' now' }, + ]); + }); }); diff --git a/nix/package-daemon.nix b/nix/package-daemon.nix index 64115ecf8..2bd6f9c19 100644 --- a/nix/package-daemon.nix +++ b/nix/package-daemon.nix @@ -148,11 +148,13 @@ in # just apps/daemon. cp -r . $out/lib/open-design/ - # Root devDependencies expose tool workspaces via pnpm symlinks, but the - # daemon derivation intentionally filters tools/ out of src because they - # are not needed at runtime. Prune the dangling symlinks from the copied - # node_modules tree so Nix fixup does not fail on broken links. + # Root devDependencies expose non-daemon workspaces via pnpm symlinks, + # but the daemon derivation intentionally filters those sources out + # when they are not needed at runtime. Prune the dangling symlinks from + # the copied node_modules tree so Nix fixup does not fail on broken + # links. rm -f \ + $out/lib/open-design/node_modules/@open-design/components \ $out/lib/open-design/node_modules/@open-design/tools-dev \ $out/lib/open-design/node_modules/@open-design/tools-pack \ $out/lib/open-design/node_modules/@open-design/tools-serve \ diff --git a/nix/pnpm-deps.nix b/nix/pnpm-deps.nix index 6e6af843f..4ddaeb6b4 100644 --- a/nix/pnpm-deps.nix +++ b/nix/pnpm-deps.nix @@ -9,6 +9,6 @@ # 1. Temporarily set the consuming `hash = lib.fakeHash;` # 2. Run the relevant nix build/flake check # 3. Copy the expected hash printed by Nix into the matching field below - daemonHash = "sha256-mfUyOYOIg1COFKEqm3ZUt9iXEileTeFfRiTff5CXYfk="; + daemonHash = "sha256-j/JjPITwavF9LOp6g+OZ9GVLD1c7fQki4D5XVXgdlNs="; webHash = "sha256-V1Ua3YEUmfJcnKGLK3y7OdFdcu6A6D1+mRvggm6Jrh4="; } diff --git a/package.json b/package.json index 8744e97ff..f05a2329d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "typecheck": "pnpm -r --workspace-concurrency=4 --if-present run typecheck && tsc -p scripts/tsconfig.json --noEmit" }, "devDependencies": { + "@open-design/components": "workspace:*", "@open-design/daemon": "workspace:*", "@open-design/tools-dev": "workspace:*", "@open-design/tools-pack": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 337f4f1a3..e52386d02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: .: devDependencies: + '@open-design/components': + specifier: workspace:* + version: link:packages/components '@open-design/daemon': specifier: workspace:* version: link:apps/daemon