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:
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user