Files
backnotprop__plannotator/packages/editor/components/AppHeader.tsx
Michael Ramos c2950e709f fix: pre-release QA findings for 0.27.9 (#1405)
Fixes from the 0.27.9 pre-release review. Servers: an unreadable rendered-HTML root falls back to the startup snapshot on both runtimes with a once-per-process warning instead of hanging (Pi) or answering 500 (Bun); the version diff is recomputed against current bytes on reload and carried through the in-app Refresh instead of being dropped, with no history write on a GET. Client: a Refresh action on the compact touch shell; HtmlSurfaceControls renders Refresh independently of the eye; the dead HtmlSurfaceActions removed. Threading: one linear, cycle-safe reply resolution shared by the annotations panel, its sort, and the export (5,000-chain tests), PATCH ingest on both runtimes rejects self-references and cycles, nothing is ever dropped from feedback. WebMCP and viewer hygiene: bounded tombstone and request memories, per-instance minted ids, nudge id caps, waiter cleanup on unmount, a shared retry epoch for diagram blocks. Docs: HTML Refresh documented, the WebMCP design pointer fixed, marketing pages updated.

AI-assisted (Claude) under maintainer direction.
2026-08-27 15:23:28 -07:00

539 lines
20 KiB
TypeScript

import React from 'react';
import type { Origin } from '@plannotator/shared/agents';
import type { Agent } from '@plannotator/ui/hooks/useAgents';
import type { UpdateInfo } from '@plannotator/ui/hooks/useUpdateCheck';
import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons';
import { ApproveDropdown } from '@plannotator/ui/components/ApproveDropdown';
import { Settings } from '@plannotator/ui/components/Settings';
import { PlanHeaderMenu } from '@plannotator/ui/components/PlanHeaderMenu';
import type { CallbackConfig } from '@plannotator/ui/utils/callback';
import type { UIPreferences } from '@plannotator/ui/utils/uiPreferences';
import { SparklesIcon } from '@plannotator/ui/components/SparklesIcon';
import type { CompactPlanAction } from '@plannotator/ui/components/PlanHeaderMenu';
import { HtmlSurfaceControls } from '@plannotator/ui/components/HtmlSurfaceControls';
/** Plannotator's refresh strings for the published control: the document
* is a file on disk, so the refresh says so. */
export const PLANNOTATOR_HTML_REFRESH_LABELS = {
refreshTitle: 'Refresh HTML from disk',
refreshingTitle: 'Refreshing HTML from disk',
} as const;
interface AppHeaderProps {
/** Mobile document-scroll surfaces let Safari own the top edge and scroll
* this header with the page. Desktop keeps the incumbent sticky header. */
sticky?: boolean;
/** HTML annotate surface (raw HTML or live app): shows the pen toggle. */
htmlSurface?: boolean;
/** Interact/Annotate toggle for HTML and live-app surfaces: armed means
* clicks annotate; unarmed hands the page back its native interaction
* (text drag-selection commenting stays live either way). */
htmlAnnotateArmed?: boolean;
onToggleHtmlAnnotate?: () => void;
/** Floating tools (sidebar tongue tabs + comment/attachments cluster) are
* fully removed from the DOM while hidden; this button is the way back. */
htmlToolsHidden?: boolean;
onToggleHtmlTools?: () => void;
canRefreshHtml?: boolean;
isRefreshingHtml?: boolean;
onRefreshHtml?: () => void;
/** Compact touch layouts replace the brand mark with a task-focused entry
* into the full-stage document navigator. Desktop never receives it. */
compactTouchLayout?: boolean;
compactNavigatorAvailable?: boolean;
compactNavigatorOpen?: boolean;
onCompactNavigatorToggle?: () => void;
compactDocumentTitle?: string;
compactSessionActions?: CompactPlanAction[];
compactDocumentActions?: CompactPlanAction[];
// Mode flags (stable after mount)
isApiMode: boolean;
annotateMode: boolean;
archiveMode: boolean;
goalSetupMode: boolean;
goalSetupCanSubmit: boolean;
goalSetupIsSubmitting: boolean;
goalSetupSubmitLabel: string;
gate: boolean;
isSharedSession: boolean;
origin: Origin | null;
// Dynamic state
isSubmitting: boolean;
isExiting: boolean;
isPanelOpen: boolean;
aiAvailable: boolean;
isAIChatOpen: boolean;
aiHasMessages: boolean;
hasAnyAnnotations: boolean;
annotationCount: number;
linkedDocIsActive: boolean;
callbackShareUrlReady: boolean;
canShareCurrentSession: boolean;
agentName: string;
availableAgents: Agent[];
showAnnotationsWarning: boolean;
annotateApproveLabel: string;
annotateApproveTitle: string;
// Callback config (null when no bot callback)
callbackConfig: CallbackConfig | null;
// Settings props
taterMode: boolean;
mobileSettingsOpen: boolean;
gitUser: string | undefined;
/** This session offers the Agent TUI, so Settings shows its Position row. */
agentTerminalAvailable: boolean;
/** The browser exposes WebMCP, so Settings shows the "Agent tools" opt-out.
* Nothing in the header renders for this alone. */
webmcpAvailable?: boolean;
/** A browser agent has completed at least one tool call in this session.
* Only then does the unobtrusive "Agent" indicator appear. */
agentConnected?: boolean;
// Handlers — App owns all decision logic, header just calls these
onCallbackFeedback: () => void;
onCallbackApprove: () => void;
onAnnotateExit: () => void;
onGoalSetupExit: () => void;
onGoalSetupSubmit: () => void;
onAnnotateFeedback: () => void;
onAnnotateApprove: () => void;
onFeedback: () => void;
onApprove: () => void;
onAnnotationPanelToggle: () => void;
onAIChatToggle: () => void;
onArchiveCopy: () => void;
onArchiveDone: () => void;
onTaterModeChange: (enabled: boolean) => void;
onIdentityChange: (oldId: string, newId: string) => void;
onUIPreferencesChange: (prefs: UIPreferences) => void;
onOpenSettings: () => void;
onCloseSettings: () => void;
onOpenExport: () => void;
onCopyAgentInstructions: () => void;
onDownloadAnnotations: () => void;
onPrint: () => void;
onCopyShareLink: () => void;
onOpenImport: () => void;
onSaveToObsidian: () => void;
onSaveToBear: () => void;
onSaveToOctarine: () => void;
// PlanHeaderMenu config
appVersion: string;
updateInfo?: UpdateInfo | null;
isWSL?: boolean;
agentInstructionsEnabled: boolean;
obsidianConfigured: boolean;
bearConfigured: boolean;
octarineConfigured: boolean;
}
export const AppHeader = React.memo<AppHeaderProps>(({
sticky = true,
htmlSurface,
htmlAnnotateArmed,
onToggleHtmlAnnotate,
htmlToolsHidden,
onToggleHtmlTools,
canRefreshHtml,
isRefreshingHtml,
onRefreshHtml,
compactTouchLayout = false,
compactNavigatorAvailable = false,
compactNavigatorOpen = false,
onCompactNavigatorToggle,
compactDocumentTitle,
compactSessionActions,
compactDocumentActions,
isApiMode,
annotateMode,
archiveMode,
goalSetupMode,
goalSetupCanSubmit,
goalSetupIsSubmitting,
goalSetupSubmitLabel,
gate,
isSharedSession,
origin,
isSubmitting,
isExiting,
isPanelOpen,
aiAvailable,
isAIChatOpen,
aiHasMessages,
hasAnyAnnotations,
annotationCount,
linkedDocIsActive,
callbackShareUrlReady,
canShareCurrentSession,
agentName,
availableAgents,
showAnnotationsWarning,
annotateApproveLabel,
annotateApproveTitle,
callbackConfig,
taterMode,
mobileSettingsOpen,
gitUser,
agentTerminalAvailable,
webmcpAvailable = false,
agentConnected = false,
onCallbackFeedback,
onCallbackApprove,
onAnnotateExit,
onGoalSetupExit,
onGoalSetupSubmit,
onAnnotateFeedback,
onAnnotateApprove,
onFeedback,
onApprove,
onAnnotationPanelToggle,
onAIChatToggle,
onArchiveCopy,
onArchiveDone,
onTaterModeChange,
onIdentityChange,
onUIPreferencesChange,
onOpenSettings,
onCloseSettings,
onOpenExport,
onCopyAgentInstructions,
onDownloadAnnotations,
onPrint,
onCopyShareLink,
onOpenImport,
onSaveToObsidian,
onSaveToBear,
onSaveToOctarine,
appVersion,
updateInfo,
isWSL,
agentInstructionsEnabled,
obsidianConfigured,
bearConfigured,
octarineConfigured,
}) => {
return (
<header
data-app-header="true"
className={`${compactTouchLayout ? 'h-[52px] grid grid-cols-[44px_minmax(0,1fr)_44px] items-center px-1' : 'h-12 flex items-center justify-between px-2 md:px-4'} border-b border-border/50 bg-card/50 backdrop-blur-xl z-[50] ${sticky ? 'sticky top-0' : 'relative'}`}
>
<div className={compactTouchLayout ? 'flex items-center justify-start' : 'flex items-center gap-2'}>
{compactTouchLayout ? (
compactNavigatorAvailable && onCompactNavigatorToggle ? (
<CompactPlanNavigatorTrigger
open={compactNavigatorOpen}
onToggle={onCompactNavigatorToggle}
/>
) : (
<span className="block h-11 w-11" aria-hidden="true" />
)
) : (
<AppHeaderLogo />
)}
</div>
{compactTouchLayout && (
<div
data-pn-compact-document-title="true"
className="min-w-0 px-2 text-center text-sm font-medium tracking-tight text-foreground"
title={compactDocumentTitle}
>
<span className="block truncate">{compactDocumentTitle || 'Plan'}</span>
</div>
)}
<div className={`flex items-center gap-1 md:gap-2 ${compactTouchLayout ? 'justify-end' : ''}`}>
{/* Bot callback buttons — only shown when ?cb=&ct= params are present */}
{!compactTouchLayout && callbackConfig && !isApiMode && isSharedSession && (
<>
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
<FeedbackButton
onClick={onCallbackFeedback}
disabled={isSubmitting || !callbackShareUrlReady}
isLoading={isSubmitting}
title="Send feedback to bot"
/>
<ApproveButton
onClick={onCallbackApprove}
disabled={isSubmitting || !callbackShareUrlReady}
isLoading={isSubmitting}
title="Approve design and notify bot"
/>
</>
)}
{!compactTouchLayout && isApiMode && !linkedDocIsActive && archiveMode && (
<>
<button
onClick={onArchiveCopy}
className="px-2.5 py-1 rounded-md text-xs font-medium transition-all bg-muted text-foreground hover:bg-muted/80 border border-border"
title="Copy plan content"
>
<span className="hidden md:inline">Copy</span>
<svg className="w-4 h-4 md:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
<button
onClick={onArchiveDone}
className="px-2.5 py-1 rounded-md text-xs font-medium transition-all bg-success text-success-foreground hover:opacity-90"
title="Close archive"
>
Done
</button>
</>
)}
{!compactTouchLayout && isApiMode && !linkedDocIsActive && goalSetupMode && (
<>
<ExitButton
onClick={onGoalSetupExit}
disabled={isExiting || goalSetupIsSubmitting}
isLoading={isExiting}
title="Close goal setup without submitting"
/>
<ApproveButton
onClick={onGoalSetupSubmit}
disabled={!goalSetupCanSubmit || goalSetupIsSubmitting || isExiting}
isLoading={goalSetupIsSubmitting}
label={goalSetupSubmitLabel}
loadingLabel="Submitting..."
mobileLabel="Submit"
title={goalSetupSubmitLabel}
/>
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
</>
)}
{!compactTouchLayout && isApiMode && (!linkedDocIsActive || annotateMode) && !archiveMode && !goalSetupMode && (
<>
{annotateMode ? (
<>
<ExitButton
onClick={onAnnotateExit}
disabled={isSubmitting || isExiting}
isLoading={isExiting}
/>
{hasAnyAnnotations && (
<FeedbackButton
onClick={onAnnotateFeedback}
disabled={isSubmitting || isExiting}
isLoading={isSubmitting}
label="Send Feedback"
title="Send Feedback"
/>
)}
</>
) : (
<FeedbackButton
onClick={onFeedback}
disabled={isSubmitting}
isLoading={isSubmitting}
label="Send Feedback"
title="Send Feedback"
/>
)}
{(!annotateMode || gate) && (
origin === 'opencode' && !annotateMode && availableAgents.length > 0 ? (
<ApproveDropdown
onApprove={onApprove}
agents={availableAgents}
disabled={isSubmitting}
isLoading={isSubmitting}
/>
) : (
<div className="relative group/approve">
<ApproveButton
onClick={onApprove}
disabled={isSubmitting || (annotateMode && isExiting)}
isLoading={isSubmitting}
dimmed={!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && showAnnotationsWarning}
label={annotateMode ? annotateApproveLabel : undefined}
mobileLabel={annotateMode ? annotateApproveLabel : undefined}
title={annotateMode ? annotateApproveTitle : undefined}
/>
{!annotateMode && (origin === 'claude-code' || origin === 'gemini-cli') && showAnnotationsWarning && (
<div className="absolute top-full right-0 mt-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl text-xs text-foreground w-56 text-center opacity-0 invisible group-hover/approve:opacity-100 group-hover/approve:visible transition-all pointer-events-none z-50">
<div className="absolute bottom-full right-4 border-4 border-transparent border-b-border" />
<div className="absolute bottom-full right-4 mt-px border-4 border-transparent border-b-popover" />
{agentName} doesn't support feedback on approval. Your feedback won't be seen.
</div>
)}
</div>
)
)}
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
</>
)}
{/* HTML and live-app surfaces only: the eye (show/hide tools, the
only way back from hidden), the refresh, and the Interact/Annotate
pen, in that order. The published control carries the markup;
the compact touch shell offers the same three actions in its
Options menu instead (compactDocumentActions in App: Show/Hide
tools, Interact/Annotate, Refresh from disk). */}
{htmlSurface && (onToggleHtmlTools || onToggleHtmlAnnotate) && (
<HtmlSurfaceControls
compact={compactTouchLayout}
armed={!!htmlAnnotateArmed}
onToggleArmed={onToggleHtmlAnnotate}
toolsHidden={!!htmlToolsHidden}
onToggleTools={onToggleHtmlTools}
canRefresh={!!canRefreshHtml && !!onRefreshHtml}
onRefresh={() => onRefreshHtml?.()}
isRefreshing={!!isRefreshingHtml}
labels={PLANNOTATOR_HTML_REFRESH_LABELS}
/>
)}
{/* WebMCP activity indicator. Deliberately absent until a browser
agent has completed a tool call: the API merely existing must not
change the page (maintainer ruling). Non-interactive; the opt-out
lives in Settings. */}
{!compactTouchLayout && agentConnected && (
<span
data-webmcp-indicator="true"
className="hidden md:inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
title="A browser agent has used Plannotator's tools in this session. Its comments are marked browser-agent. Turn the tools off in Settings."
>
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" aria-hidden="true" />
Agent
</span>
)}
{/* Annotations panel toggle */}
{!compactTouchLayout && !goalSetupMode && (
<button
onClick={onAnnotationPanelToggle}
className={`relative p-1.5 rounded-md text-xs font-medium transition-all ${
isPanelOpen
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title={isPanelOpen ? 'Hide annotations' : 'Show annotations'}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
{annotationCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] flex items-center justify-center rounded-full bg-primary text-[8px] font-bold text-primary-foreground px-0.5">
{annotationCount > 99 ? '99+' : annotationCount}
</span>
)}
</button>
)}
{!compactTouchLayout && !goalSetupMode && aiAvailable && (
<button
onClick={onAIChatToggle}
className={`relative p-1.5 rounded-md text-xs font-medium transition-all ${
isAIChatOpen
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title={isAIChatOpen ? 'Hide AI chat' : 'Show AI chat'}
aria-label={isAIChatOpen ? 'Hide AI chat' : 'Show AI chat'}
>
<SparklesIcon className="w-4 h-4" />
{aiHasMessages && !isAIChatOpen && (
<span className="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-primary" />
)}
</button>
)}
{/* Settings dialog (controlled, button hidden — opened from PlanHeaderMenu) */}
<div className="hidden">
<Settings
taterMode={taterMode}
onTaterModeChange={onTaterModeChange}
onIdentityChange={onIdentityChange}
origin={origin}
mode={annotateMode ? 'annotate' : 'plan'}
onUIPreferencesChange={onUIPreferencesChange}
externalOpen={mobileSettingsOpen}
onExternalClose={onCloseSettings}
gitUser={gitUser}
agentTerminalAvailable={agentTerminalAvailable}
webmcpAvailable={webmcpAvailable}
/>
</div>
<PlanHeaderMenu
appVersion={appVersion}
updateInfo={updateInfo}
origin={origin}
isWSL={isWSL}
onOpenSettings={onOpenSettings}
onOpenExport={onOpenExport}
onCopyAgentInstructions={onCopyAgentInstructions}
onDownloadAnnotations={onDownloadAnnotations}
onPrint={onPrint}
onCopyShareLink={onCopyShareLink}
onOpenImport={onOpenImport}
onSaveToObsidian={onSaveToObsidian}
onSaveToBear={onSaveToBear}
onSaveToOctarine={onSaveToOctarine}
sharingEnabled={canShareCurrentSession}
isApiMode={isApiMode}
agentInstructionsEnabled={agentInstructionsEnabled}
obsidianConfigured={!archiveMode && !goalSetupMode && obsidianConfigured}
bearConfigured={!archiveMode && !goalSetupMode && bearConfigured}
octarineConfigured={!archiveMode && !goalSetupMode && octarineConfigured}
compactTouchLayout={compactTouchLayout}
compactSessionActions={compactSessionActions}
compactDocumentActions={compactDocumentActions}
/>
</div>
</header>
);
});
export const CompactPlanNavigatorTrigger = ({
open,
onToggle,
}: {
open: boolean;
onToggle: () => void;
}) => (
<button
id="pn-compact-plan-navigator-trigger"
type="button"
onClick={onToggle}
data-pn-touch-target="true"
data-pn-touch-target-icon="true"
data-pn-compact-navigator-trigger="true"
className={`flex h-11 w-11 items-center justify-center rounded-lg text-sm font-semibold tracking-tight outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/60 ${
open
? 'bg-primary/15 text-primary'
: 'text-foreground hover:bg-muted'
}`}
aria-label={open ? 'Close plan navigator' : 'Open plan navigator'}
aria-expanded={open}
aria-controls="pn-compact-plan-navigator"
title={open ? 'Close navigator' : 'Navigate plan'}
>
<svg className="h-[18px] w-[18px] flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 6h14M5 12h14M5 18h9" />
</svg>
<span className="sr-only">Plan navigation</span>
</button>
);
const AppHeaderLogo = () => (
<div className="flex items-center gap-2 md:gap-3">
<a
href="https://plannotator.ai"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 md:gap-2 hover:opacity-80 transition-opacity"
>
<span className="text-sm font-semibold tracking-tight">Plannotator</span>
</a>
</div>
);