v1.13.19-html-artifact-panes: pane-based artifact viewer with on-request HTML
Every assistant message gets an "Open in pane" affordance that opens the
message in the workspace splitter — Markdown pane (Copy + Download .md) by
default; HTML pane (Download .html only) when the model emits a self-contained
<!DOCTYPE html> or fenced ```html artifact. BOOCHAT.md rule keeps Markdown
default at every length; HTML opt-in on explicit user request.
Backend: services/artifacts.ts (slug derivation + write helpers with
symlink-escape guard via realpath-after-mkdir), routes/artifacts.ts (POST
download + GET stream with nosniff + CSP sandbox defense-in-depth), HTML
detection in finalizeCompletion writing a new message_parts.kind='html_artifact'
row (schema CHECK extended via v1.13.13 pattern), graceful 1MB cap via the
pure decideHtmlArtifactWrite helper. PartKind union extended.
Frontend: MarkdownRenderer.tsx extracted from MessageBubble's inline
MarkdownBody for reuse; MarkdownArtifactPane.tsx + HtmlArtifactPane.tsx with
loading/error states; pane state is reference-only ({chat_id, message_id,
title}) — content fetched on mount to keep workspace_panes jsonb small and
avoid 1MB blobs riding session_workspace_updated frames. iframe sandbox
locked to allow-scripts allow-clipboard-write allow-downloads with no
allow-same-origin, srcDoc not src. openInPane discriminates 404 (expected
fallback) from real errors (toast + bail). PanelRightOpen icon button with
mobile 44px tap-target.
31 new server unit tests including a real-symlink filesystem case; 332/332
server tests passing, tsc clean both sides, pnpm -C apps/web build green.
Smoke deferred to first deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,14 @@
|
||||
// across hooks (e.g. AI rename arriving via WS in the session view needs to
|
||||
// also refresh the sidebar's session list).
|
||||
|
||||
import type { Chat, ErrorReason, Project, Session } from '@/api/types';
|
||||
import type {
|
||||
Chat,
|
||||
ErrorReason,
|
||||
HtmlArtifactState,
|
||||
MarkdownArtifactState,
|
||||
Project,
|
||||
Session,
|
||||
} from '@/api/types';
|
||||
import type { Attachment } from '@/lib/attachments';
|
||||
|
||||
export interface SessionRenamedEvent {
|
||||
@@ -68,6 +75,19 @@ export interface OpenChatInActivePaneEvent {
|
||||
chat_id: string;
|
||||
}
|
||||
|
||||
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of
|
||||
// these; useWorkspacePanes subscribes and inserts the corresponding artifact
|
||||
// pane (or focuses an existing one keyed by message_id).
|
||||
export interface OpenMarkdownArtifactPaneEvent {
|
||||
type: 'open_markdown_artifact_pane';
|
||||
state: MarkdownArtifactState;
|
||||
}
|
||||
|
||||
export interface OpenHtmlArtifactPaneEvent {
|
||||
type: 'open_html_artifact_pane';
|
||||
state: HtmlArtifactState;
|
||||
}
|
||||
|
||||
// Client-side event fired by the sidebar Settings button when a session is
|
||||
// currently mounted. Session.tsx subscribes and calls
|
||||
// panesHook.toggleSettingsPane() (open on first click, close on second).
|
||||
@@ -154,6 +174,8 @@ export type SessionEvent =
|
||||
| OpenFileInBrowserEvent
|
||||
| AttachChatFileEvent
|
||||
| OpenChatInActivePaneEvent
|
||||
| OpenMarkdownArtifactPaneEvent
|
||||
| OpenHtmlArtifactPaneEvent
|
||||
| OpenSettingsPaneEvent
|
||||
| SessionArchivedEvent
|
||||
| ChatCreatedEvent
|
||||
|
||||
@@ -154,6 +154,11 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
||||
case 'open_chat_in_active_pane':
|
||||
// Consumed by Workspace; sidebar has no business with pane state.
|
||||
return prev;
|
||||
case 'open_markdown_artifact_pane':
|
||||
case 'open_html_artifact_pane':
|
||||
// v1.14.x-html-artifact-panes: consumed by useWorkspacePanes; sidebar
|
||||
// has no business with pane state.
|
||||
return prev;
|
||||
case 'open_settings_pane':
|
||||
// Consumed by Session.tsx (calls toggleSettingsPane on its panesHook).
|
||||
// Sidebar data is untouched.
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { WorkspacePane } from '@/api/types';
|
||||
import type {
|
||||
HtmlArtifactState,
|
||||
MarkdownArtifactState,
|
||||
WorkspacePane,
|
||||
} from '@/api/types';
|
||||
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
|
||||
@@ -43,6 +47,28 @@ function settingsPane(): WorkspacePane {
|
||||
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
|
||||
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
|
||||
// the pane row so the sessions.workspace_panes jsonb survives reload.
|
||||
function markdownArtifactPane(state: MarkdownArtifactState): WorkspacePane {
|
||||
return {
|
||||
id: generateId(),
|
||||
kind: 'markdown_artifact',
|
||||
chatIds: [],
|
||||
activeChatIdx: -1,
|
||||
markdown_artifact_state: state,
|
||||
};
|
||||
}
|
||||
|
||||
function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
|
||||
return {
|
||||
id: generateId(),
|
||||
kind: 'html_artifact',
|
||||
chatIds: [],
|
||||
activeChatIdx: -1,
|
||||
html_artifact_state: state,
|
||||
};
|
||||
}
|
||||
|
||||
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
|
||||
// page reload always returns to a clean workspace; the user re-opens via the
|
||||
// sidebar Settings button when needed.
|
||||
@@ -169,6 +195,50 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" emits one of
|
||||
// these per click. If a pane already exists for the same message_id, focus
|
||||
// it instead of stacking a duplicate. Otherwise append (capped at MAX_PANES;
|
||||
// settings panes don't count, matching addSplitPane's rule).
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((ev) => {
|
||||
if (
|
||||
ev.type !== 'open_markdown_artifact_pane' &&
|
||||
ev.type !== 'open_html_artifact_pane'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPanes((prev) => {
|
||||
const targetKind: WorkspacePane['kind'] =
|
||||
ev.type === 'open_html_artifact_pane' ? 'html_artifact' : 'markdown_artifact';
|
||||
const messageId = ev.state.message_id;
|
||||
const existingIdx = prev.findIndex((p) =>
|
||||
p.kind === 'markdown_artifact'
|
||||
? p.markdown_artifact_state?.message_id === messageId
|
||||
: p.kind === 'html_artifact'
|
||||
? p.html_artifact_state?.message_id === messageId
|
||||
: false,
|
||||
);
|
||||
if (existingIdx >= 0) {
|
||||
setActivePaneIdx(existingIdx);
|
||||
return prev;
|
||||
}
|
||||
if (nonSettingsCount(prev) >= MAX_PANES) {
|
||||
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||
return prev;
|
||||
}
|
||||
const newPane =
|
||||
ev.type === 'open_html_artifact_pane'
|
||||
? htmlArtifactPane(ev.state)
|
||||
: markdownArtifactPane(ev.state);
|
||||
// Defensive: assert kind matches for the discriminated union.
|
||||
if (newPane.kind !== targetKind) return prev;
|
||||
const next = [...prev, newPane];
|
||||
setActivePaneIdx(next.length - 1);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// v1.12.1: debounced PATCH on every change. Settings panes are stripped
|
||||
// before saving (ephemeral per v1.9).
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user