feat: single-source cross-app wire contracts in @boocode/contracts (v2.7.13)

Move all hand-synced cross-app wire contracts into one built workspace
package, @boocode/contracts, consumed by server/web/coder/coder-web via
workspace:* + a per-subpath exports map. The ws-frames and provider-config
Zod schemas are schema-first (z.infer); MessageMetadata, ErrorReason,
AgentSessionConfig, the provider snapshot types, and WorktreeRiskReport are
each single-sourced. Deletes the byte-identical copies and their parity
tests, fixes a live AgentSessionConfig drift (coder dead copy removed,
unified to the web required/nullable shape), removes the dead pending_change
WS arms in the fallback SPA, and inverts the build order (contracts builds
first) across root build, Dockerfile, and the coder deploy docs. Reverses
the shared-package decision declined in v2.5.12.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 21:00:00 +00:00
parent 2a05d2f9fe
commit 649ce71eff
55 changed files with 804 additions and 1108 deletions

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@boocode/contracts": "workspace:*",
"lucide-react": "^1.16.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",

View File

@@ -1,5 +1,22 @@
// Minimal types for the BooCoder frontend.
// Shared DB entities (same schema as BooChat).
//
// WS wire contracts are single-sourced from @boocode/contracts (the canonical
// Zod-backed schema). The DB entity types below (Project/Session/Chat/Message/
// ToolCall/ToolResult/PendingChange) are an intentional minimal SPA-local subset
// and are NOT cross-app contracts — they stay defined here.
import type { WsFrame } from '@boocode/contracts/ws-frames';
// Re-export the canonical WebSocket frame union (single source of truth). The
// coder backend publishes the full frame set; this SPA's reducer handles the
// subset it renders and ignores the rest.
export type { WsFrame };
// The error frame's `reason`, single-sourced from the canonical schema's
// frame-level reason enum (derived from WsFrame so it cannot drift from the
// wire). Distinct from message-metadata's ErrorReason, which is a different set.
export type ErrorReason = NonNullable<Extract<WsFrame, { type: 'error' }>['reason']>;
export interface Project {
id: string;
@@ -39,7 +56,9 @@ export interface ToolResult {
tool_call_id: string;
output: unknown;
truncated?: boolean;
error?: boolean;
// Canonical wire shape: the failure message string (present only on error),
// not a boolean. ToolResultBubble treats it as truthy → renders error styling.
error?: string;
}
// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
@@ -96,15 +115,3 @@ export interface PendingChange {
created_at: string;
applied_at: string | null;
}
// WebSocket frame types (subset of what the coder backend publishes)
export type WsFrame =
| { type: 'snapshot'; messages: Message[] }
| { type: 'message_started'; message_id: string; chat_id: string; role: Message['role'] }
| { type: 'delta'; message_id: string; chat_id: string; content: string }
| { type: 'tool_call'; message_id: string; chat_id: string; tool_call: ToolCall }
| { type: 'tool_result'; tool_message_id: string; chat_id: string; tool_call_id: string; output: string; truncated?: boolean; error?: boolean }
| { type: 'message_complete'; message_id: string; chat_id: string; tokens_used?: number; ctx_used?: number; ctx_max?: number; started_at?: string; finished_at?: string; metadata?: unknown }
| { type: 'error'; message_id?: string; error: string; reason?: string }
| { type: 'pending_change_added'; change: PendingChange }
| { type: 'pending_change_updated'; change: PendingChange };

View File

@@ -5,10 +5,9 @@ import { api } from '@/api/client';
interface Props {
sessionId: string;
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
}
export function DiffPane({ sessionId, onPendingChange }: Props) {
export function DiffPane({ sessionId }: Props) {
const [changes, setChanges] = useState<PendingChange[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -24,27 +23,13 @@ export function DiffPane({ sessionId, onPendingChange }: Props) {
}
}, [sessionId]);
// Initial load
// Initial load. Pending changes are delivered over HTTP (list + apply/reject/
// rewind below); there is no WS pending-change frame, so the list refreshes on
// mount, on the Refresh button, and optimistically as the user acts on it.
useEffect(() => {
fetchPending();
}, [fetchPending]);
// Listen for WS pending change events
useEffect(() => {
const unsub = onPendingChange((change) => {
setChanges((prev) => {
const idx = prev.findIndex((c) => c.id === change.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = change;
return next;
}
return [...prev, change];
});
});
return unsub;
}, [onPendingChange]);
const pendingChanges = changes.filter((c) => c.status === 'pending');
const resolvedChanges = changes.filter((c) => c.status !== 'pending');

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import type { Message, WsFrame, PendingChange } from '@/api/types';
import { useEffect, useRef, useState } from 'react';
import type { Message, WsFrame } from '@/api/types';
interface State {
messages: Message[];
@@ -10,7 +10,9 @@ interface State {
function applyFrame(state: State, frame: WsFrame): State {
switch (frame.type) {
case 'snapshot': {
return { ...state, messages: frame.messages };
// Canonical SnapshotFrame.messages is opaque (z.array(z.unknown())); the
// coder backend sends Message-shaped rows, so cast to the SPA's local type.
return { ...state, messages: frame.messages as Message[] };
}
case 'message_started': {
const exists = state.messages.some((m) => m.id === frame.message_id);
@@ -18,7 +20,7 @@ function applyFrame(state: State, frame: WsFrame): State {
const newMsg: Message = {
id: frame.message_id,
session_id: '',
chat_id: frame.chat_id,
chat_id: frame.chat_id ?? '',
role: frame.role,
content: '',
kind: 'message',
@@ -72,7 +74,7 @@ function applyFrame(state: State, frame: WsFrame): State {
const newMsg: Message = {
id: frame.tool_message_id,
session_id: '',
chat_id: frame.chat_id,
chat_id: frame.chat_id ?? '',
role: 'tool',
content: '',
kind: 'message',
@@ -119,9 +121,12 @@ function applyFrame(state: State, frame: WsFrame): State {
: state.messages;
return { ...state, messages: next, error: frame.error };
}
case 'pending_change_added':
case 'pending_change_updated':
// These are handled by the pending changes listener, not the message state
default:
// The canonical WsFrame carries the full set of frames the coder backend
// can publish; this SPA only renders the subset handled above and safely
// ignores the rest (reasoning_delta, usage, permission_*, agent_*, and the
// per-user sidebar frames). pending_change_* frames have no publisher —
// pending changes are delivered over HTTP, so there is nothing to handle.
return state;
}
}
@@ -134,14 +139,11 @@ interface SessionStreamResult {
connected: boolean;
error: string | null;
isStreaming: boolean;
/** Listeners for pending change frames */
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
}
export function useSessionStream(sessionId: string | undefined): SessionStreamResult {
const [state, setState] = useState<State>({ messages: [], connected: false, error: null });
const wsRef = useRef<WebSocket | null>(null);
const pendingListenersRef = useRef<Set<(change: PendingChange) => void>>(new Set());
useEffect(() => {
if (!sessionId) return;
@@ -172,13 +174,6 @@ export function useSessionStream(sessionId: string | undefined): SessionStreamRe
return;
}
// Notify pending change listeners
if (frame.type === 'pending_change_added' || frame.type === 'pending_change_updated') {
for (const cb of pendingListenersRef.current) {
cb(frame.change);
}
}
setState((s) => applyFrame(s, frame));
};
@@ -213,18 +208,10 @@ export function useSessionStream(sessionId: string | undefined): SessionStreamRe
const isStreaming = state.messages.some((m) => m.status === 'streaming');
const onPendingChange = useCallback((cb: (change: PendingChange) => void) => {
pendingListenersRef.current.add(cb);
return () => {
pendingListenersRef.current.delete(cb);
};
}, []);
return {
messages: state.messages,
connected: state.connected,
error: state.error,
isStreaming,
onPendingChange,
};
}

View File

@@ -14,8 +14,7 @@ export function Session() {
const [chat, setChat] = useState<Chat | null>(null);
const [loading, setLoading] = useState(true);
const { messages, connected, isStreaming, onPendingChange } =
useSessionStream(sessionId);
const { messages, connected, isStreaming } = useSessionStream(sessionId);
// Get or create a chat for this session
useEffect(() => {
@@ -78,9 +77,7 @@ export function Session() {
connected={connected}
/>
}
diffPane={
<DiffPane sessionId={sessionId} onPendingChange={onPendingChange} />
}
diffPane={<DiffPane sessionId={sessionId} />}
/>
);
}