A cohesive batch of pane/tab UX + the persisted workspace-state model (grouped
because the changes interleave across useWorkspacePanes, ChatTabBar, Workspace,
sessionEvents and the api types/client):
- Open a whole chat in a fresh pane via a new open_chat_in_new_pane event:
ChatTabBar tab context menu "Open in new pane", and MessageBubble.fork() now
lands the fork beside the original instead of replacing the active pane.
openChatInNewPane detaches the chat from any pane already holding it
(one-chat-per-pane).
- The tab-bar "+" becomes a New BooChat/BooTerm/BooCode menu (chat as a tab,
term/coder as split panes); the split button is unchanged.
- Drop the per-message "Open in pane" button (it opened a single message's
artifact) and its dead code; the artifact-pane machinery is left orphaned for
a later teardown.
- Session history: the empty/landing pane lists the session's open chats plus
archived chats (fetched separately), click to open / restore-and-open.
- Relocate-on-close: closing a chat pane moves its tabs (in order) into the
oldest chat/empty pane instead of discarding them; terminal/coder panes close
as before. Reopen strips the restored chatIds from all live panes first, so a
relocated-then-reopened pane never duplicates a tab — no stack-shape change.
- Stable global tab numbering: tabNumbers/nextTabNumber assigned on chat-pane
open, retired on close (never reused), rendered map-keyed (not positional).
- workspace_panes is now a WorkspaceState envelope { panes, tabNumbers,
nextTabNumber, closedPaneStack }; the reopen stack moved from a module-level
array into the persisted envelope so it survives reload. Hydrate/persist
normalize the legacy bare-array shape. appendClosed dedupes a value-identical
top entry to neutralize the StrictMode double-invoke of the setPanes updater.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
382 lines
10 KiB
TypeScript
382 lines
10 KiB
TypeScript
// v1.13.11-a: Zod schemas for every WebSocket frame published by the server.
|
|
// Validation runs both on send (broker.publishFrame / publishUserFrame) and
|
|
// on receive (apps/web/src/hooks/useSessionStream + useUserEvents). Catches
|
|
// silent protocol drift between publisher and consumer.
|
|
//
|
|
// IMPORTANT: This file is duplicated byte-identical at
|
|
// apps/web/src/api/ws-frames.ts. The two apps have separate tsconfigs and
|
|
// no path alias; the duplication is sync-by-hand. A test asserts the two
|
|
// files match. If you change one, change the other.
|
|
//
|
|
// Per-kind payload schemas (tool_call args, message_parts payloads, etc.)
|
|
// stay z.unknown() in v1.13.11. Frame-level drift detection is the goal;
|
|
// deep payload validation is follow-up work.
|
|
|
|
import { z } from 'zod';
|
|
|
|
// ---- shared primitives -----------------------------------------------------
|
|
|
|
const Uuid = z.string().uuid();
|
|
// Tool call IDs are model-emitted (e.g. "call_abc123") — not UUIDs.
|
|
const ToolCallId = z.string().min(1);
|
|
// v1.13.12 fix: postgres returns timestamp columns as JS Date objects, not
|
|
// strings. The publish sites pass them through unchanged, so the schema must
|
|
// tolerate both. preprocess converts Date → ISO string before string-validation;
|
|
// on the web side (where frames arrive via JSON.parse) it's a no-op. Before
|
|
// this fix, every message_complete / session_updated / chat_updated frame
|
|
// failed validation and got dropped — symptoms: token tracking blank in UI,
|
|
// status stuck at 'streaming' tripping the 60s stale-stream banner.
|
|
const IsoTimestamp = z.preprocess(
|
|
(v) => (v instanceof Date ? v.toISOString() : v),
|
|
z.string().min(1),
|
|
);
|
|
|
|
const ChatStatusValue = z.enum([
|
|
'streaming',
|
|
'tool_running',
|
|
'waiting_for_input',
|
|
'idle',
|
|
'error',
|
|
]);
|
|
|
|
const ErrorReasonValue = z.enum([
|
|
'llm_provider_error',
|
|
'doom_loop',
|
|
'doom_loop_summary_failed',
|
|
'cap_hit',
|
|
'cap_hit_summary_failed',
|
|
]);
|
|
|
|
const MessageRoleValue = z.enum(['user', 'assistant', 'system', 'tool']);
|
|
|
|
const ToolCallShape = z.object({
|
|
id: ToolCallId,
|
|
name: z.string().min(1),
|
|
args: z.record(z.string(), z.unknown()),
|
|
});
|
|
|
|
// Free-form bags: opaque to the frame schema; deep validation is out of
|
|
// scope for v1.13.11 (frame-level drift detection is the goal; per-kind
|
|
// payload narrowing is follow-up work). z.unknown() means the consumer
|
|
// must narrow before reading — TypeScript-side this is fine because every
|
|
// consumer already operates on the hand-maintained Project / Chat / Session
|
|
// / WorkspacePane types (the brief's "Don't strip existing types yet"
|
|
// rule), and the Zod-typed shape is only used at the publishFrame boundary.
|
|
const OpaqueObject = z.unknown();
|
|
|
|
// ---- per-session channel frames --------------------------------------------
|
|
|
|
export const SnapshotFrame = z.object({
|
|
type: z.literal('snapshot'),
|
|
messages: z.array(OpaqueObject),
|
|
});
|
|
|
|
export const MessageStartedFrame = z.object({
|
|
type: z.literal('message_started'),
|
|
message_id: Uuid,
|
|
chat_id: Uuid.optional(),
|
|
role: MessageRoleValue,
|
|
});
|
|
|
|
export const DeltaFrame = z.object({
|
|
type: z.literal('delta'),
|
|
message_id: Uuid,
|
|
chat_id: Uuid.optional(),
|
|
content: z.string(),
|
|
});
|
|
|
|
export const ReasoningDeltaFrame = z.object({
|
|
type: z.literal('reasoning_delta'),
|
|
message_id: Uuid,
|
|
chat_id: Uuid.optional(),
|
|
content: z.string(),
|
|
});
|
|
|
|
export const ToolCallFrame = z.object({
|
|
type: z.literal('tool_call'),
|
|
message_id: Uuid,
|
|
chat_id: Uuid.optional(),
|
|
tool_call: ToolCallShape,
|
|
});
|
|
|
|
export const ToolResultFrame = z.object({
|
|
type: z.literal('tool_result'),
|
|
tool_message_id: Uuid,
|
|
chat_id: Uuid.optional(),
|
|
tool_call_id: ToolCallId,
|
|
output: z.unknown(),
|
|
truncated: z.boolean(),
|
|
error: z.string().optional(),
|
|
});
|
|
|
|
export const MessageCompleteFrame = z.object({
|
|
type: z.literal('message_complete'),
|
|
message_id: Uuid,
|
|
chat_id: Uuid.optional(),
|
|
tokens_used: z.number().int().nonnegative().nullable().optional(),
|
|
ctx_used: z.number().int().nonnegative().nullable().optional(),
|
|
ctx_max: z.number().int().positive().nullable().optional(),
|
|
started_at: IsoTimestamp.nullable().optional(),
|
|
finished_at: IsoTimestamp.nullable().optional(),
|
|
model: z.string().optional(),
|
|
metadata: OpaqueObject.nullable().optional(),
|
|
});
|
|
|
|
export const UsageFrame = z.object({
|
|
type: z.literal('usage'),
|
|
message_id: Uuid,
|
|
chat_id: Uuid.optional(),
|
|
completion_tokens: z.number().int().nonnegative().nullable(),
|
|
ctx_used: z.number().int().nonnegative().nullable(),
|
|
ctx_max: z.number().int().positive().nullable(),
|
|
});
|
|
|
|
export const MessagesDeletedFrame = z.object({
|
|
type: z.literal('messages_deleted'),
|
|
message_ids: z.array(Uuid),
|
|
chat_id: Uuid.optional(),
|
|
});
|
|
|
|
export const ChatRenamedFrame = z.object({
|
|
type: z.literal('chat_renamed'),
|
|
chat_id: Uuid,
|
|
name: z.string(),
|
|
});
|
|
|
|
export const CompactedFrame = z.object({
|
|
type: z.literal('compacted'),
|
|
session_id: Uuid,
|
|
chat_id: Uuid,
|
|
summary_message_id: Uuid,
|
|
});
|
|
|
|
export const ErrorFrame = z.object({
|
|
type: z.literal('error'),
|
|
message_id: Uuid.optional(),
|
|
chat_id: Uuid.optional(),
|
|
error: z.string(),
|
|
reason: ErrorReasonValue.optional(),
|
|
});
|
|
|
|
// ---- per-user channel frames (sidebar refresh) -----------------------------
|
|
|
|
export const ChatStatusFrame = z.object({
|
|
type: z.literal('chat_status'),
|
|
chat_id: Uuid,
|
|
status: ChatStatusValue,
|
|
at: IsoTimestamp,
|
|
reason: ErrorReasonValue.optional(),
|
|
});
|
|
|
|
export const SessionUpdatedFrame = z.object({
|
|
type: z.literal('session_updated'),
|
|
session_id: Uuid,
|
|
project_id: Uuid,
|
|
name: z.string(),
|
|
updated_at: IsoTimestamp,
|
|
});
|
|
|
|
export const SessionRenamedFrame = z.object({
|
|
type: z.literal('session_renamed'),
|
|
session_id: Uuid,
|
|
name: z.string(),
|
|
});
|
|
|
|
export const SessionCreatedFrame = z.object({
|
|
type: z.literal('session_created'),
|
|
session: OpaqueObject,
|
|
project_id: Uuid,
|
|
});
|
|
|
|
export const SessionArchivedFrame = z.object({
|
|
type: z.literal('session_archived'),
|
|
session_id: Uuid,
|
|
project_id: Uuid,
|
|
});
|
|
|
|
export const SessionDeletedFrame = z.object({
|
|
type: z.literal('session_deleted'),
|
|
session_id: Uuid,
|
|
project_id: Uuid,
|
|
});
|
|
|
|
export const SessionWorkspaceUpdatedFrame = z.object({
|
|
type: z.literal('session_workspace_updated'),
|
|
session_id: Uuid,
|
|
// v2.6.x: widened from z.array — the payload is now either the legacy bare
|
|
// WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
|
|
// nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
|
|
// every envelope frame at validation. MUST be mirrored in the server's
|
|
// byte-identical copy (parity test).
|
|
workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
|
|
});
|
|
|
|
export const ChatCreatedFrame = z.object({
|
|
type: z.literal('chat_created'),
|
|
chat: OpaqueObject,
|
|
session_id: Uuid,
|
|
});
|
|
|
|
export const ChatUpdatedFrame = z.object({
|
|
type: z.literal('chat_updated'),
|
|
chat_id: Uuid,
|
|
session_id: Uuid,
|
|
name: z.string().nullable(),
|
|
updated_at: IsoTimestamp,
|
|
});
|
|
|
|
export const ChatArchivedFrame = z.object({
|
|
type: z.literal('chat_archived'),
|
|
chat_id: Uuid,
|
|
session_id: Uuid,
|
|
});
|
|
|
|
export const ChatUnarchivedFrame = z.object({
|
|
type: z.literal('chat_unarchived'),
|
|
chat: OpaqueObject,
|
|
});
|
|
|
|
export const ChatDeletedFrame = z.object({
|
|
type: z.literal('chat_deleted'),
|
|
chat_id: Uuid,
|
|
session_id: Uuid,
|
|
});
|
|
|
|
export const ProjectCreatedFrame = z.object({
|
|
type: z.literal('project_created'),
|
|
project: OpaqueObject,
|
|
});
|
|
|
|
export const ProjectArchivedFrame = z.object({
|
|
type: z.literal('project_archived'),
|
|
project_id: Uuid,
|
|
});
|
|
|
|
export const ProjectUnarchivedFrame = z.object({
|
|
type: z.literal('project_unarchived'),
|
|
project: OpaqueObject,
|
|
});
|
|
|
|
export const ProjectUpdatedFrame = z.object({
|
|
type: z.literal('project_updated'),
|
|
project_id: Uuid,
|
|
name: z.string(),
|
|
});
|
|
|
|
export const ProjectDeletedFrame = z.object({
|
|
type: z.literal('project_deleted'),
|
|
project_id: Uuid,
|
|
});
|
|
|
|
const PermissionOptionShape = z.object({
|
|
option_id: z.string(),
|
|
label: z.string(),
|
|
});
|
|
|
|
export const PermissionRequestedFrame = z.object({
|
|
type: z.literal('permission_requested'),
|
|
task_id: Uuid,
|
|
session_id: Uuid,
|
|
kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
|
|
tool_title: z.string().optional(),
|
|
input: z.record(z.unknown()).optional(),
|
|
options: z.array(PermissionOptionShape),
|
|
});
|
|
|
|
export const PermissionResolvedFrame = z.object({
|
|
type: z.literal('permission_resolved'),
|
|
task_id: Uuid,
|
|
session_id: Uuid,
|
|
});
|
|
|
|
const AgentCommandShape = z.object({
|
|
name: z.string(),
|
|
description: z.string().optional(),
|
|
});
|
|
|
|
export const AgentCommandsFrame = z.object({
|
|
type: z.literal('agent_commands'),
|
|
task_id: Uuid,
|
|
session_id: Uuid,
|
|
commands: z.array(AgentCommandShape),
|
|
});
|
|
|
|
// ---- discriminated union ---------------------------------------------------
|
|
|
|
export const WsFrameSchema = z.discriminatedUnion('type', [
|
|
// per-session
|
|
SnapshotFrame,
|
|
MessageStartedFrame,
|
|
DeltaFrame,
|
|
ReasoningDeltaFrame,
|
|
ToolCallFrame,
|
|
ToolResultFrame,
|
|
MessageCompleteFrame,
|
|
UsageFrame,
|
|
MessagesDeletedFrame,
|
|
ChatRenamedFrame,
|
|
CompactedFrame,
|
|
ErrorFrame,
|
|
PermissionRequestedFrame,
|
|
PermissionResolvedFrame,
|
|
AgentCommandsFrame,
|
|
// per-user
|
|
ChatStatusFrame,
|
|
SessionUpdatedFrame,
|
|
SessionRenamedFrame,
|
|
SessionCreatedFrame,
|
|
SessionArchivedFrame,
|
|
SessionDeletedFrame,
|
|
SessionWorkspaceUpdatedFrame,
|
|
ChatCreatedFrame,
|
|
ChatUpdatedFrame,
|
|
ChatArchivedFrame,
|
|
ChatUnarchivedFrame,
|
|
ChatDeletedFrame,
|
|
ProjectCreatedFrame,
|
|
ProjectArchivedFrame,
|
|
ProjectUnarchivedFrame,
|
|
ProjectUpdatedFrame,
|
|
ProjectDeletedFrame,
|
|
]);
|
|
|
|
export type WsFrame = z.infer<typeof WsFrameSchema>;
|
|
|
|
// Convenience: the set of known frame types. Useful for the publishFrame
|
|
// helper to log the offending type name when validation fails. Kept in sync
|
|
// by hand with the discriminated union above.
|
|
export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
|
|
'snapshot',
|
|
'message_started',
|
|
'delta',
|
|
'reasoning_delta',
|
|
'tool_call',
|
|
'tool_result',
|
|
'message_complete',
|
|
'usage',
|
|
'messages_deleted',
|
|
'chat_renamed',
|
|
'compacted',
|
|
'error',
|
|
'permission_requested',
|
|
'permission_resolved',
|
|
'agent_commands',
|
|
'chat_status',
|
|
'session_updated',
|
|
'session_renamed',
|
|
'session_created',
|
|
'session_archived',
|
|
'session_deleted',
|
|
'session_workspace_updated',
|
|
'chat_created',
|
|
'chat_updated',
|
|
'chat_archived',
|
|
'chat_unarchived',
|
|
'chat_deleted',
|
|
'project_created',
|
|
'project_archived',
|
|
'project_unarchived',
|
|
'project_updated',
|
|
'project_deleted',
|
|
] as const;
|