+
Thinking
@@ -144,87 +129,6 @@ function AssistantWaitingCard() {
);
}
-function parseSessionError(thrown: unknown): SessionError {
- const raw = thrown instanceof Error ? thrown.message : String(thrown);
- // Try to detect ProviderModelNotFoundError from the SDK error shape.
- // The error message may be a JSON string from our serializer in session-route.
- try {
- const parsed = JSON.parse(raw);
- if (parsed?.name === "ProviderModelNotFoundError" && parsed?.data) {
- const { providerID, modelID, suggestions } = parsed.data;
- return {
- message: `Model ${providerID}/${modelID} is not available.`,
- kind: "model-not-found",
- failedModel: { providerID, modelID },
- suggestions: Array.isArray(suggestions) ? suggestions : [],
- };
- }
- } catch {
- // Not JSON — fall through to plain message
- }
- // Check if the raw string mentions model-not-found patterns
- if (/ProviderModelNotFoundError/i.test(raw) || /model.*not found/i.test(raw)) {
- return { message: raw, kind: "model-not-found" };
- }
- return { message: raw || "Failed to send prompt." };
-}
-
-function SessionErrorCard({ error, onDismiss, onChangeModel, onOpenModelPicker }: {
- error: SessionError;
- onDismiss: () => void;
- onChangeModel?: (model: { providerID: string; modelID: string }) => void;
- onOpenModelPicker?: () => void;
-}) {
- return (
-
-
-
-
-
{error.message}
- {error.kind === "model-not-found" ? (
-
- {error.suggestions && error.suggestions.length > 0 ? (
- error.suggestions.map((s) => (
-
- ))
- ) : null}
-
-
- ) : null}
-
-
-
-
-
- );
-}
-
function revokeAttachmentPreview(attachment: { previewUrl?: string | undefined }) {
if (!attachment.previewUrl) return;
URL.revokeObjectURL(attachment.previewUrl);
@@ -236,7 +140,7 @@ export function SessionSurface(props: SessionSurfaceProps) {
const [mentions, setMentions] = useState
>({});
const [pasteParts, setPasteParts] = useState>([]);
const [notice, setNotice] = useState(null);
- const [error, setError] = useState(null);
+ const [error, setError] = useState(null);
const [sending, setSending] = useState(false);
const [showDelayedLoading, setShowDelayedLoading] = useState(false);
const [awaitingAssistantBaseline, setAwaitingAssistantBaseline] = useState(null);
@@ -245,7 +149,7 @@ export function SessionSurface(props: SessionSurfaceProps) {
const [toolMcpServers, setToolMcpServers] = useState([]);
const [toolMcpStatus, setToolMcpStatus] = useState(null);
const [toolMcpStatuses, setToolMcpStatuses] = useState({});
- const [toolImportedPlugins, setToolImportedPlugins] = useState([]);
+ const [showAudit, setShowAudit] = useState(false);
const hydratedKeyRef = useRef(null);
const attachmentsRef = useRef([]);
attachmentsRef.current = attachments;
@@ -266,6 +170,21 @@ export function SessionSurface(props: SessionSurfaceProps) {
() => reactStatusKey(props.workspaceId, props.sessionId),
[props.workspaceId, props.sessionId],
);
+ const todoQueryKey = useMemo(
+ () => reactTodoKey(props.workspaceId, props.sessionId),
+ [props.workspaceId, props.sessionId],
+ );
+
+ useEffect(() => {
+ return () => {
+ const queryClient = getReactQueryClient();
+ queryClient.removeQueries({ queryKey: snapshotQueryKey, exact: true });
+ queryClient.removeQueries({ queryKey: transcriptQueryKey, exact: true });
+ queryClient.removeQueries({ queryKey: statusQueryKey, exact: true });
+ queryClient.removeQueries({ queryKey: todoQueryKey, exact: true });
+ };
+ }, [snapshotQueryKey, transcriptQueryKey, statusQueryKey, todoQueryKey]);
+
const snapshotQuery = useQuery({
queryKey: snapshotQueryKey,
queryFn: async () => (await props.client.getSessionSnapshot(props.workspaceId, props.sessionId, { limit: 140 })).item,
@@ -273,8 +192,9 @@ export function SessionSurface(props: SessionSurfaceProps) {
});
const currentSnapshot = snapshotQuery.data?.session.id === props.sessionId ? snapshotQuery.data : null;
- const transcriptState = useSharedQueryState(transcriptQueryKey, EMPTY_TRANSCRIPT);
- const statusState = useSharedQueryState(statusQueryKey, currentSnapshot?.status ?? IDLE_STATUS);
+ const transcriptState = useSharedQueryState(transcriptQueryKey, []);
+ const statusState = useSharedQueryState(statusQueryKey, currentSnapshot?.status ?? { type: "idle" as const });
+ useSharedQueryState(todoQueryKey, currentSnapshot?.todos ?? []);
useEffect(() => {
if (!currentSnapshot) return;
@@ -371,17 +291,10 @@ export function SessionSurface(props: SessionSurfaceProps) {
seedSessionState(props.workspaceId, currentSnapshot);
}, [props.sessionId, currentSnapshot, props.workspaceId]);
- const snapshot = resolveRenderedSessionSnapshot({
- sessionId: props.sessionId,
- currentSnapshot,
- cachedRendered: rendered,
- });
- const liveStatus = statusState ?? snapshot?.status ?? IDLE_STATUS;
+ const snapshot = currentSnapshot ?? rendered?.snapshot ?? null;
+ const liveStatus = statusState ?? snapshot?.status ?? { type: "idle" as const };
const chatStreaming = sending || liveStatus.type === "busy" || liveStatus.type === "retry";
- const renderedMessages = useMemo(
- () => deriveRenderedSessionMessages({ transcriptState, snapshot }),
- [snapshot, transcriptState],
- );
+ const renderedMessages = transcriptState ?? [];
const pendingSessionLoad = !snapshot && snapshotQuery.isLoading && renderedMessages.length === 0;
const assistantOutputAfterAwaitStart = useMemo(() => {
if (awaitingAssistantBaseline === null) return false;
@@ -423,13 +336,13 @@ export function SessionSurface(props: SessionSurfaceProps) {
const model = deriveSessionRenderModel({
intendedSessionId: props.sessionId,
- renderedSessionId: renderedMessages.length > 0 || snapshot ? props.sessionId : null,
+ renderedSessionId: renderedMessages.length > 0 || snapshotQuery.data ? props.sessionId : rendered?.sessionId ?? null,
hasSnapshot: Boolean(snapshot) || renderedMessages.length > 0,
isFetching: snapshotQuery.isFetching,
isError: snapshotQuery.isError || Boolean(error),
});
- const buildDraft = useCallback((text: string, nextAttachments: ComposerAttachment[]): ComposerDraft => {
+ const buildDraft = (text: string, nextAttachments: ComposerAttachment[]): ComposerDraft => {
const trimmed = text.trim();
const slashMatch = trimmed.match(/^\/([^\s]+)\s*(.*)$/);
const parts: ComposerPart[] = text.split(/(\[pasted text [^\]]+\]|@[^\s@]+)/).flatMap((segment) => {
@@ -449,27 +362,21 @@ export function SessionSurface(props: SessionSurfaceProps) {
}
return [{ type: "text", text: segment } satisfies ComposerDraft["parts"][number]];
});
- // Expand paste placeholders in resolvedText so the model receives
- // the actual pasted content instead of "[pasted text
-
{
- sessionScroll.markScrollGesture(event.target);
- }}
- onTouchStart={(event) => {
- sessionScroll.markScrollGesture(event.target);
- }}
- onTouchMove={(event) => {
- sessionScroll.markScrollGesture(event.target);
- }}
- onPointerDown={(event) => {
- if (event.target !== event.currentTarget) return;
- sessionScroll.markScrollGesture(event.currentTarget);
- }}
- onScroll={sessionScroll.handleScroll}
- className="absolute inset-0 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 py-4 sm:px-5"
+
+
diff --git a/apps/app/src/react-app/domains/session/sync/session-audit-store.ts b/apps/app/src/react-app/domains/session/sync/session-audit-store.ts
new file mode 100644
index 000000000..ff265a32f
--- /dev/null
+++ b/apps/app/src/react-app/domains/session/sync/session-audit-store.ts
@@ -0,0 +1,399 @@
+import type { ToolPart } from "@opencode-ai/sdk/v2/client";
+
+import { createClient } from "../../../../app/lib/opencode";
+import { normalizeEvent, safeStringify } from "../../../../app/utils";
+
+const MAX_AUDIT_ENTRIES = 500;
+const SUMMARY_MAX_CHARS = 220;
+
+type Listener = () => void;
+
+type AuditEntrySource = "tool" | "pty" | "session-error";
+type AuditEntryStatus = "pending" | "running" | "completed" | "error";
+
+export type AuditEntry = {
+ id: string;
+ source: AuditEntrySource;
+ sessionId: string;
+ timestamp: number;
+ title: string;
+ status: AuditEntryStatus;
+ inputSummary: string;
+ outputSummary: string;
+ toolName?: string;
+ ptyId?: string;
+ callId?: string;
+};
+
+export type SessionAuditSnapshot = {
+ entries: AuditEntry[];
+ connected: boolean;
+ error: string | null;
+};
+
+export type SessionAuditStore = {
+ subscribe: (listener: Listener) => () => void;
+ getSnapshot: () => SessionAuditSnapshot;
+ dispose: () => void;
+};
+
+type CreateSessionAuditStoreInput = {
+ opencodeBaseUrl: string;
+ openworkToken: string;
+ sessionId: string;
+};
+
+type UnknownRecord = Record
;
+
+function asRecord(value: unknown): UnknownRecord | null {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
+ return value as UnknownRecord;
+}
+
+function readString(record: UnknownRecord, key: string): string {
+ const value = record[key];
+ return typeof value === "string" ? value : "";
+}
+
+function readNumber(record: UnknownRecord, key: string): number | null {
+ const value = record[key];
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+function summarizeValue(value: unknown, maxChars = SUMMARY_MAX_CHARS): string {
+ if (value === undefined || value === null) return "";
+ const text = typeof value === "string" ? value : safeStringify(value);
+ const compact = text.replace(/\s+/g, " ").trim();
+ if (!compact) return "";
+ if (compact.length <= maxChars) return compact;
+ return `${compact.slice(0, maxChars - 1)}...`;
+}
+
+function mapToolStatus(status: string): AuditEntryStatus {
+ if (status === "completed") return "completed";
+ if (status === "error") return "error";
+ if (status === "running") return "running";
+ return "pending";
+}
+
+function getErrorMessage(value: unknown): string {
+ const record = asRecord(value);
+ if (!record) return "";
+ const direct = readString(record, "message");
+ if (direct) return direct;
+ const data = asRecord(record.data);
+ if (!data) return "";
+ return readString(data, "message");
+}
+
+function trimEntries(entries: AuditEntry[]): AuditEntry[] {
+ if (entries.length <= MAX_AUDIT_ENTRIES) return entries;
+ return entries.slice(entries.length - MAX_AUDIT_ENTRIES);
+}
+
+export function createSessionAuditStore(input: CreateSessionAuditStoreInput): SessionAuditStore {
+ const listeners = new Set();
+ const abortController = new AbortController();
+ const toolEntryByPartId = new Map();
+ const ptyEntryById = new Map();
+
+ let nextId = 0;
+ let disposed = false;
+ let started = false;
+ let snapshot: SessionAuditSnapshot = {
+ entries: [],
+ connected: false,
+ error: null,
+ };
+
+ const emit = () => {
+ for (const listener of listeners) listener();
+ };
+
+ const setSnapshotAndEmit = (updater: (current: SessionAuditSnapshot) => SessionAuditSnapshot) => {
+ snapshot = updater(snapshot);
+ emit();
+ };
+
+ const setSnapshotSilently = (updater: (current: SessionAuditSnapshot) => SessionAuditSnapshot) => {
+ snapshot = updater(snapshot);
+ };
+
+ const addEntry = (entry: AuditEntry) => {
+ setSnapshotAndEmit((current) => ({
+ ...current,
+ entries: trimEntries([...current.entries, entry]),
+ }));
+ };
+
+ const updateEntry = (entryId: string, updater: (entry: AuditEntry) => AuditEntry) => {
+ setSnapshotAndEmit((current) => {
+ const index = current.entries.findIndex((item) => item.id === entryId);
+ if (index === -1) return current;
+ const entries = current.entries.slice();
+ entries[index] = updater(entries[index]!);
+ return { ...current, entries };
+ });
+ };
+
+ const createEntryId = () => {
+ nextId += 1;
+ return `audit:${input.sessionId}:${Date.now()}:${nextId}`;
+ };
+
+ const handleToolPartUpdated = (properties: UnknownRecord) => {
+ const part = properties.part;
+ const partRecord = asRecord(part);
+ if (!partRecord) return;
+ if (readString(partRecord, "type") !== "tool") return;
+
+ const partSessionId = readString(partRecord, "sessionID");
+ const eventSessionId = readString(properties, "sessionID");
+ const ownerSessionId = partSessionId || eventSessionId;
+ if (ownerSessionId !== input.sessionId) return;
+
+ const toolPart = part as ToolPart;
+ const stateRecord = asRecord(toolPart.state);
+ if (!stateRecord) return;
+
+ const partId = readString(partRecord, "id");
+ if (!partId) return;
+
+ const timestamp = readNumber(properties, "time") ?? Date.now();
+ const status = mapToolStatus(readString(stateRecord, "status"));
+ const inputSummary = summarizeValue(stateRecord.input);
+ const outputSummary =
+ status === "completed"
+ ? summarizeValue(stateRecord.output)
+ : status === "error"
+ ? summarizeValue(stateRecord.error)
+ : "";
+
+ const title = toolPart.tool?.trim() ? toolPart.tool.trim() : "Tool";
+ const existingId = toolEntryByPartId.get(partId) ?? null;
+
+ if (!existingId) {
+ const entryId = createEntryId();
+ toolEntryByPartId.set(partId, entryId);
+ addEntry({
+ id: entryId,
+ source: "tool",
+ sessionId: input.sessionId,
+ timestamp,
+ title,
+ status,
+ inputSummary,
+ outputSummary,
+ toolName: title,
+ callId: readString(partRecord, "callID") || undefined,
+ });
+ return;
+ }
+
+ updateEntry(existingId, (current) => ({
+ ...current,
+ timestamp,
+ status,
+ inputSummary: inputSummary || current.inputSummary,
+ outputSummary: outputSummary || current.outputSummary,
+ title: title || current.title,
+ toolName: title || current.toolName,
+ callId: readString(partRecord, "callID") || current.callId,
+ }));
+ };
+
+ const buildPtyCommand = (info: UnknownRecord) => {
+ const command = readString(info, "command");
+ const argsRaw = info.args;
+ const args = Array.isArray(argsRaw) ? argsRaw.filter((item): item is string => typeof item === "string") : [];
+ return [command, ...args].filter(Boolean).join(" ").trim();
+ };
+
+ const handlePtyCreatedOrUpdated = (properties: UnknownRecord) => {
+ const info = asRecord(properties.info);
+ if (!info) return;
+ const ptyId = readString(info, "id");
+ if (!ptyId) return;
+
+ const title = readString(info, "title") || "Shell command";
+ const commandSummary = buildPtyCommand(info);
+ const statusText = readString(info, "status");
+ const status: AuditEntryStatus =
+ statusText === "running" ? "running" : statusText === "exited" ? "completed" : "pending";
+ const timestamp = Date.now();
+ const existingId = ptyEntryById.get(ptyId) ?? null;
+
+ if (!existingId) {
+ const entryId = createEntryId();
+ ptyEntryById.set(ptyId, entryId);
+ addEntry({
+ id: entryId,
+ source: "pty",
+ sessionId: input.sessionId,
+ timestamp,
+ title,
+ status,
+ inputSummary: commandSummary,
+ outputSummary: "",
+ ptyId,
+ });
+ return;
+ }
+
+ updateEntry(existingId, (current) => ({
+ ...current,
+ timestamp,
+ title: title || current.title,
+ status,
+ inputSummary: commandSummary || current.inputSummary,
+ }));
+ };
+
+ const handlePtyExited = (properties: UnknownRecord) => {
+ const ptyId = readString(properties, "id");
+ if (!ptyId) return;
+ const exitCode = readNumber(properties, "exitCode");
+ const existingId = ptyEntryById.get(ptyId) ?? null;
+ const timestamp = Date.now();
+ const outputSummary = exitCode === null ? "Exited" : `Exited with code ${exitCode}`;
+ const status: AuditEntryStatus = exitCode === null || exitCode === 0 ? "completed" : "error";
+
+ if (!existingId) {
+ const entryId = createEntryId();
+ ptyEntryById.set(ptyId, entryId);
+ addEntry({
+ id: entryId,
+ source: "pty",
+ sessionId: input.sessionId,
+ timestamp,
+ title: "Shell command",
+ status,
+ inputSummary: "",
+ outputSummary,
+ ptyId,
+ });
+ return;
+ }
+
+ updateEntry(existingId, (current) => ({
+ ...current,
+ timestamp,
+ status,
+ outputSummary,
+ }));
+ };
+
+ const handleSessionError = (properties: UnknownRecord) => {
+ const sessionId = readString(properties, "sessionID");
+ if (sessionId && sessionId !== input.sessionId) return;
+
+ const message = getErrorMessage(properties.error) || "Session failed";
+ addEntry({
+ id: createEntryId(),
+ source: "session-error",
+ sessionId: input.sessionId,
+ timestamp: Date.now(),
+ title: "Session error",
+ status: "error",
+ inputSummary: "",
+ outputSummary: summarizeValue(message),
+ });
+ };
+
+ const hydrateFromSessionHistory = async (client: ReturnType) => {
+ try {
+ const response = await client.session.messages({ sessionID: input.sessionId, limit: 200 });
+ const responseRecord = asRecord(response);
+ const messagesRaw = responseRecord?.data;
+ if (!Array.isArray(messagesRaw)) return;
+ for (const message of messagesRaw) {
+ const messageRecord = asRecord(message);
+ const partsRaw = messageRecord?.parts;
+ if (!Array.isArray(partsRaw)) continue;
+ for (const part of partsRaw) {
+ const partRecord = asRecord(part);
+ if (!partRecord) continue;
+ if (readString(partRecord, "type") !== "tool") continue;
+ const timeRecord = asRecord(asRecord(partRecord.state)?.time);
+ const time = readNumber(timeRecord ?? {}, "start") ?? Date.now();
+ handleToolPartUpdated({
+ sessionID: input.sessionId,
+ part,
+ time,
+ });
+ }
+ }
+ } catch {
+ // Non-fatal: realtime SSE updates still flow when available.
+ }
+ };
+
+ const start = async () => {
+ const client = createClient(input.opencodeBaseUrl, undefined, {
+ token: input.openworkToken,
+ mode: "openwork",
+ });
+
+ try {
+ const subscription = await client.event.subscribe(undefined, { signal: abortController.signal });
+ setSnapshotSilently((current) => ({ ...current, connected: true, error: null }));
+ await hydrateFromSessionHistory(client);
+
+ for await (const raw of subscription.stream) {
+ if (disposed || abortController.signal.aborted) return;
+ const event = normalizeEvent(raw);
+ if (!event) continue;
+ const properties = asRecord(event.properties);
+ if (!properties) continue;
+
+ if (event.type === "message.part.updated") {
+ handleToolPartUpdated(properties);
+ continue;
+ }
+ if (event.type === "pty.created" || event.type === "pty.updated") {
+ handlePtyCreatedOrUpdated(properties);
+ continue;
+ }
+ if (event.type === "pty.exited") {
+ handlePtyExited(properties);
+ continue;
+ }
+ if (event.type === "session.error") {
+ handleSessionError(properties);
+ }
+ }
+ } catch (error) {
+ if (abortController.signal.aborted) return;
+ const message = error instanceof Error ? error.message : "Failed to subscribe to audit events.";
+ setSnapshotSilently((current) => ({ ...current, connected: false, error: message }));
+ } finally {
+ if (!disposed) {
+ setSnapshotSilently((current) => ({ ...current, connected: false }));
+ }
+ }
+ };
+ const ensureStarted = () => {
+ if (started || disposed) return;
+ started = true;
+ void start();
+ };
+
+ return {
+ subscribe(listener) {
+ listeners.add(listener);
+ ensureStarted();
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ getSnapshot() {
+ return snapshot;
+ },
+ dispose() {
+ if (disposed) return;
+ disposed = true;
+ abortController.abort();
+ listeners.clear();
+ },
+ };
+}