diff --git a/apps/desktop/src/components/assistant-ui/inline-preview-directive.tsx b/apps/desktop/src/components/assistant-ui/inline-preview-directive.tsx index b086c3f2fc..916312ef37 100644 --- a/apps/desktop/src/components/assistant-ui/inline-preview-directive.tsx +++ b/apps/desktop/src/components/assistant-ui/inline-preview-directive.tsx @@ -1,11 +1,11 @@ import { useStore } from '@nanostores/react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useSessionView } from '@/app/chat/session-view' -import { PreviewAttachment } from '@/components/chat/preview-attachment' import { useIsDark } from '@/components/assistant-ui/embeds/use-is-dark' -import { isRemoteGateway } from '@/lib/media' +import { PreviewAttachment } from '@/components/chat/preview-attachment' import { localPreviewTarget } from '@/lib/local-preview' +import { isRemoteGateway } from '@/lib/media' /** * `::preview{file="…"}` — a workspace HTML file rendered LIVE inside the @@ -221,9 +221,7 @@ function InlineHtmlFrame({ const [doc, setDoc] = useState(null) const [failed, setFailed] = useState(false) const [measured, setMeasured] = useState(null) - const heightRef = useRef(initialHeight ?? DEFAULT_HEIGHT) const [contentWidth, setContentWidth] = useState(null) - const adoptedWidthRef = useRef(null) // One token per mount: the message listener only trusts reports from the // document THIS mount injected, so two previews in one transcript (or a @@ -270,25 +268,27 @@ function InlineHtmlFrame({ return } - if (Math.abs(next.height - heightRef.current) > RESIZE_TOLERANCE) { - heightRef.current = next.height - setMeasured(next.height) - } + // Functional updates so the comparisons read current state without a + // shadow ref: same-value sets bail out in React, and the tolerance + // keeps a vh-sized page (which measures what it's given) from + // oscillating. + setMeasured(prev => + Math.abs(next.height - (prev ?? initialHeight ?? DEFAULT_HEIGHT)) > RESIZE_TOLERANCE ? next.height : prev + ) // Width adopts ONCE, from the first report — measured at full column // width, so it is the content's intrinsic span. Tracking width live // would feedback-loop: %-width children reflow narrower every time // the frame shrinks, spiraling toward zero. - if (adoptedWidthRef.current === null && next.width > 0) { - adoptedWidthRef.current = next.width - setContentWidth(next.width) + if (next.width > 0) { + setContentWidth(prev => prev ?? next.width) } } window.addEventListener('message', onMessage) return () => window.removeEventListener('message', onMessage) - }, [token]) + }, [initialHeight, token]) // Resolved once per mount; theme switches remount the transcript anyway. const framedDoc = useMemo(() => { diff --git a/apps/desktop/src/components/assistant-ui/transcript-directive.tsx b/apps/desktop/src/components/assistant-ui/transcript-directive.tsx index b3904515a5..dd8ed9fc5c 100644 --- a/apps/desktop/src/components/assistant-ui/transcript-directive.tsx +++ b/apps/desktop/src/components/assistant-ui/transcript-directive.tsx @@ -1,4 +1,5 @@ import type { FC, ReactNode } from 'react' +import { useMemo } from 'react' import { type Contribution, useContributions } from '@/contrib' import { ContribBoundary, ContribRender } from '@/contrib/react/boundary' @@ -40,21 +41,29 @@ function claimFor(contributions: readonly Contribution[], name: string) { export const TranscriptDirectiveLeaf: FC<{ text: string; streaming?: boolean }> = ({ text, streaming }) => { const contributions = useContributions(TRANSCRIPT_DIRECTIVE_AREA) - const parsed = parseTranscriptDirective(text) + const parsed = useMemo(() => parseTranscriptDirective(text), [text]) const match = parsed ? claimFor(contributions, parsed.name) : undefined const contribution = match?.data as TranscriptDirectiveContribution | undefined + const render = contribution?.render - if (!parsed || !match || !contribution?.render) { + // Stable component identity for ContribRender (which mounts this AS a + // component): a fresh closure per render would remount the widget on + // every parent render. + const renderLeaf = useMemo( + () => + render && parsed + ? () => render({ attrs: parsed.attrs, source: parsed.source, streaming: streaming ?? false }) + : null, + [render, parsed, streaming] + ) + + if (!match || !renderLeaf) { return null } return ( - - contribution.render({ attrs: parsed.attrs, source: parsed.source, streaming: streaming ?? false }) - } - /> + ) } diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 82f60bfba9..b914f76b7d 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -457,13 +457,6 @@ export { export { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib' export { type RouteContribution, ROUTES_AREA, SIDEBAR_NAV_AREA, type SidebarNavContribution } from '@/app/routes' -/** The transcript as a contribution area: register a named `::directive{...}` - * and the model can render your component inline in assistant messages. */ -export { - TRANSCRIPT_DIRECTIVE_AREA, - type TranscriptDirectiveContribution, - type TranscriptDirectiveProps -} from '@/lib/transcript-directives' /** THE full per-toolset config panel core Settings renders — provider picker, * env vars / API keys, model catalog picker, and post-setup runners. Route- * decoupled (the "manage keys" deep link is a no-op outside the router); pass @@ -482,8 +475,8 @@ export { type ModelMenuController } from '@/app/shell/model-catalog-menu' export type { StatusbarItem } from '@/app/shell/statusbar-controls' - export type { TitlebarTool } from '@/app/shell/titlebar-controls' + /** THE whole Capabilities surface (Skills / Tools / MCP tabs, installed * lists, full-skill detail pane, embedded hub picker with one-click * installs). For plugin dialogs pass `embedded` (tab state stays local — @@ -564,15 +557,15 @@ export type { PluginRestOptions, PluginStorage } from '@/contrib/plugin' - -// -- contracts ---------------------------------------------------------------- - /** Mount-scoped contribution: while the rendering component is mounted, its * children render in the target area's slot; unmount disposes it. Use for * page-owned chrome (a page's titlebar control leaves with the page) — * `ctx.register` stays the door for permanent contributions. Namespace the * id with your plugin slug (`kanban:board-switcher`). */ export { Contribute, type ContributeProps } from '@/contrib/react/contribute' + +// -- contracts ---------------------------------------------------------------- + export type { Contribution } from '@/contrib/types' /** The live gateway instance type — for typing the `gateway` prop `McpTab` * takes; obtain the instance from `host.getGateway()`. */ @@ -609,8 +602,6 @@ export { profileColor, profileColorSoft } from '@/lib/profile-color' * `ctx.socket` frame invalidating a query). Inside components keep using * `useQueryClient`. */ export { queryClient } from '@/lib/query-client' - -export const PANES_AREA = 'panes' /** Hermes' reasoning levels + their compact labels, so a plugin surfacing a * thinking depth uses the same scale and spelling as the rest of the app. */ export { @@ -620,14 +611,23 @@ export { type ReasoningEffort, reasoningEffortLabel } from '@/lib/reasoning-effort' -export const STATUSBAR_AREAS = { left: 'statusBar.left', right: 'statusBar.right' } as const -export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left', right: 'titleBar.right' } as const +export const PANES_AREA = 'panes' /** The app's own gateway-readiness evaluation (setup.status + * setup.runtime_check, reconciled) — pass `host.request`. Don't hand-roll * readiness from raw RPC shapes. */ export { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' +export const STATUSBAR_AREAS = { left: 'statusBar.left', right: 'statusBar.right' } as const +export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left', right: 'titleBar.right' } as const + export { coarseElapsed, fmtDateTime, fmtDayTime, relativeTime } from '@/lib/time' +/** The transcript as a contribution area: register a named `::directive{...}` + * and the model can render your component inline in assistant messages. */ +export { + TRANSCRIPT_DIRECTIVE_AREA, + type TranscriptDirectiveContribution, + type TranscriptDirectiveProps +} from '@/lib/transcript-directives' export { cn } from '@/lib/utils' export { THEMES_AREA } from '@/themes/user-themes' export type { RpcEvent, StatusResponse } from '@/types/hermes'