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:
committed by
brooklyn!
parent
20ec564684
commit
aeabff6aec
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user