fix(desktop): satisfy the plugin-render and import-order lint rules

CI's check:lint caught three real issues in the directive surface:

- TranscriptDirectiveLeaf called the contribution's render() inline in JSX
  — the exact pattern no-restricted-syntax bans because the callback's hooks
  land in the host and a plugin reload changes the host hook count
  (React #310). The callback is now memoized and mounted via ContribRender.
- The inline frame mirrored its height state into a ref from the message
  handler (the stale-read pattern no-restricted-syntax flags). Functional
  setState reads current state directly; the shadow refs are gone.
- perfectionist import/export ordering in the frame and the SDK index.
This commit is contained in:
Brooklyn Nicholson
2026-08-17 15:09:03 -05:00
committed by brooklyn!
parent 20ec564684
commit aeabff6aec
3 changed files with 44 additions and 35 deletions
@@ -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<string | null>(null)
const [failed, setFailed] = useState(false)
const [measured, setMeasured] = useState<number | null>(null)
const heightRef = useRef<number>(initialHeight ?? DEFAULT_HEIGHT)
const [contentWidth, setContentWidth] = useState<number | null>(null)
const adoptedWidthRef = useRef<number | null>(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(() => {
@@ -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 (
<ContribBoundary id={match.id} variant="chip">
<ContribRender
render={() =>
contribution.render({ attrs: parsed.attrs, source: parsed.source, streaming: streaming ?? false })
}
/>
<ContribRender render={renderLeaf} />
</ContribBoundary>
)
}
+15 -15
View File
@@ -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'