Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions dashboard/src/v2/hooks/use-cinematic-invocation-feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import type { ExecutionInvocationRecord } from "../types.js";
import { fetchInvocationMessages } from "../lib/invocation-api.js";
import {
projectCinematicInvocationFeedback,
selectCinematicFeedbackInvocation,
} from "../lib/cinematic-invocation-feedback.js";

export interface UseCinematicInvocationFeedbackOptions {
invocations: readonly ExecutionInvocationRecord[];
projectId: string | null | undefined;
projectManagerAgentPresetId: string | null | undefined;
}

export interface UseCinematicInvocationFeedbackResult {
activeInvocation: ExecutionInvocationRecord | null;
message: string | null;
toolCount: number;
loading: boolean;
error: string | null;
}

interface FeedbackSnapshot {
error: string | null;
invocationId: string | null;
loading: boolean;
message: string | null;
projectId: string | null;
toolCount: number;
}

const EMPTY_FEEDBACK: FeedbackSnapshot = {
error: null,
invocationId: null,
loading: false,
message: null,
projectId: null,
toolCount: 0,
};

const errorMessage = (error: unknown): string => (
error instanceof Error ? error.message : String(error)
);

export const useCinematicInvocationFeedback = (
options: UseCinematicInvocationFeedbackOptions,
): UseCinematicInvocationFeedbackResult => {
const { invocations, projectId, projectManagerAgentPresetId } = options;
const requestGenerationRef = useRef(0);
const [snapshot, setSnapshot] = useState<FeedbackSnapshot>(EMPTY_FEEDBACK);

const activeInvocation = useMemo(() => {
if (!projectId) return null;
return selectCinematicFeedbackInvocation(
invocations.filter((invocation) => invocation.projectId === projectId),
projectManagerAgentPresetId,
);
}, [invocations, projectId, projectManagerAgentPresetId]);

const activeInvocationId = activeInvocation?.id ?? null;
const activeMessageCount = activeInvocation?.messageCount ?? null;
const activeLastMessageAt = activeInvocation?.lastMessageAt ?? null;
const activeUpdatedAt = activeInvocation?.updatedAt ?? null;

useEffect(() => {
const requestGeneration = requestGenerationRef.current + 1;
requestGenerationRef.current = requestGeneration;
const abortController = new AbortController();

if (!projectId || !activeInvocationId) {
setSnapshot((current) => (
current.invocationId === null
&& current.projectId === null
&& !current.loading
&& current.message === null
&& current.toolCount === 0
&& current.error === null
? current
: EMPTY_FEEDBACK
));
return () => {
abortController.abort();
if (requestGenerationRef.current === requestGeneration) {
requestGenerationRef.current += 1;
}
};
}

setSnapshot((current) => {
const sameInvocation = current.projectId === projectId
&& current.invocationId === activeInvocationId;
return {
error: null,
invocationId: activeInvocationId,
loading: true,
message: sameInvocation ? current.message : null,
projectId,
toolCount: sameInvocation ? current.toolCount : 0,
};
});

void fetchInvocationMessages(activeInvocationId, { signal: abortController.signal })
.then((messages) => {
if (
abortController.signal.aborted
|| requestGenerationRef.current !== requestGeneration
) {
return;
}

const feedback = projectCinematicInvocationFeedback(messages);
setSnapshot({
error: null,
invocationId: activeInvocationId,
loading: false,
message: feedback.message,
projectId,
toolCount: feedback.toolCount,
});
})
.catch((error: unknown) => {
if (
abortController.signal.aborted
|| requestGenerationRef.current !== requestGeneration
) {
return;
}

setSnapshot((current) => {
if (
current.projectId !== projectId
|| current.invocationId !== activeInvocationId
) {
return current;
}
return {
...current,
error: errorMessage(error),
loading: false,
};
});
});

return () => {
abortController.abort();
if (requestGenerationRef.current === requestGeneration) {
requestGenerationRef.current += 1;
}
};
}, [
activeInvocationId,
activeLastMessageAt,
activeMessageCount,
activeUpdatedAt,
projectId,
]);

const snapshotMatchesActiveInvocation = Boolean(
projectId
&& activeInvocationId
&& snapshot.projectId === projectId
&& snapshot.invocationId === activeInvocationId,
);

return {
activeInvocation,
message: snapshotMatchesActiveInvocation ? snapshot.message : null,
toolCount: snapshotMatchesActiveInvocation ? snapshot.toolCount : 0,
loading: snapshotMatchesActiveInvocation ? snapshot.loading : Boolean(activeInvocation),
error: snapshotMatchesActiveInvocation ? snapshot.error : null,
};
};
121 changes: 121 additions & 0 deletions dashboard/src/v2/lib/cinematic-invocation-feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type {
ExecutionInvocationMessageRecord,
ExecutionInvocationRecord,
} from "../types.js";

export type CinematicFeedbackInvocation = Pick<
ExecutionInvocationRecord,
| "agentPresetId"
| "id"
| "lastMessageAt"
| "messageCount"
| "startedAt"
| "status"
| "type"
| "updatedAt"
>;

export interface CinematicInvocationTranscriptFeedback {
message: string | null;
toolCount: number;
}

const REPLY_INVOCATION_TYPES = new Set(["dashboard_reply", "worker_reply"]);
const PROSE_MESSAGE_KINDS = new Set(["assistant"]);
const TOOL_MESSAGE_KINDS = new Set(["tool_call", "tool_result"]);

const readMetadataString = (
message: ExecutionInvocationMessageRecord,
key: string,
): string | null => {
const value = message.metadata?.[key];
if (typeof value !== "string") return null;
const normalized = value.trim();
return normalized || null;
};

/**
* Selects only current Project Manager reply work. Invocation-pane selection
* and unrelated project activity deliberately have no bearing on this result.
*/
export const selectCinematicFeedbackInvocation = <T extends CinematicFeedbackInvocation>(
invocations: readonly T[],
projectManagerAgentPresetId: string | null | undefined,
): T | null => {
if (!projectManagerAgentPresetId) return null;

let selected: T | null = null;
for (const invocation of invocations) {
if (
invocation.status !== "running"
|| invocation.agentPresetId !== projectManagerAgentPresetId
|| !REPLY_INVOCATION_TYPES.has(invocation.type)
) {
continue;
}

if (
!selected
|| invocation.startedAt > selected.startedAt
|| (invocation.startedAt === selected.startedAt && invocation.id > selected.id)
) {
selected = invocation;
}
}

return selected;
};

const isAssistantProseMessage = (
message: ExecutionInvocationMessageRecord,
): boolean => {
if (message.role !== "assistant" || !message.contentMarkdown.trim()) {
return false;
}

const kind = readMetadataString(message, "kind");
return kind === null || PROSE_MESSAGE_KINDS.has(kind);
};

/** Returns the latest safe assistant prose without exposing internal turns. */
export const selectLatestCinematicAssistantMessage = (
messages: readonly ExecutionInvocationMessageRecord[],
): string | null => {
let selected: ExecutionInvocationMessageRecord | null = null;

for (const message of messages) {
if (!isAssistantProseMessage(message)) continue;
// The invocation API returns the persisted transcript in sequence order.
selected = message;
}

return selected?.contentMarkdown.trim() || null;
};

/**
* Counts logical tool activity from normalized turn metadata. Calls and their
* paired results share one key; stable message ids cover providers without a
* call id and keep repeated transcript refreshes idempotent.
*/
export const countUniqueCinematicToolCalls = (
messages: readonly ExecutionInvocationMessageRecord[],
): number => {
const toolKeys = new Set<string>();

for (const message of messages) {
const kind = readMetadataString(message, "kind");
if (!kind || !TOOL_MESSAGE_KINDS.has(kind)) continue;

const toolCallId = readMetadataString(message, "toolCallId");
toolKeys.add(toolCallId ? `call:${toolCallId}` : `message:${message.id}`);
}

return toolKeys.size;
};

export const projectCinematicInvocationFeedback = (
messages: readonly ExecutionInvocationMessageRecord[],
): CinematicInvocationTranscriptFeedback => ({
message: selectLatestCinematicAssistantMessage(messages),
toolCount: countUniqueCinematicToolCalls(messages),
});
4 changes: 4 additions & 0 deletions docs-web/architecture/execution-invocation-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ Jules remains outside this local CLI parser and watcher path. Its remote session

Chat's Invocations rail is server-authoritative. It reads the paginated `GET /api/projects/:projectId/execution/invocations` projection; `project.execution.updated` and `snapshot_required` trigger REST refetches for the list and selected transcript instead of creating browser-only invocation rows.

The cinematic feedback model is separate from whichever invocation is selected in that rail. Only the latest running `dashboard_reply` or `worker_reply` for the resolved Project Manager preset is eligible, with `startedAt` and invocation id providing deterministic precedence. The model loads the persisted transcript through the existing invocation-message endpoint and exposes only non-empty normalized assistant prose. User/system turns, injected context, reasoning, tool arguments, and tool output are never promoted into stage copy.

Logical tool activity is deduplicated by normalized `metadata.toolCallId`; a stable message id is the fallback only when no call id exists. The frontend refreshes this projection when the active invocation or its `messageCount`, `lastMessageAt`, or `updatedAt` changes, preserves same-invocation feedback during refresh, and aborts or generation-invalidates stale work after project/invocation changes. Terminal or missing invocations clear the feedback. A transcript request failure remains a local, non-fatal state and does not replace the normal chat transcript or make unrelated work foreground activity.

Startup recovery reconciles stale workflow and provider rows from durable task-run, sprint-run, dispatch, process, and Docker-container evidence. Preparation-only rows can fail without provider linkage, terminal provider rows are reconciled without extending their usage window, and a recovered completed provider attempt may continue from its preserved workspace without a duplicate provider run.

## Focused verification
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ Jules remains outside this local CLI parser and watcher path. Its remote session

Chat's Invocations rail is server-authoritative. It reads the paginated `GET /api/projects/:projectId/execution/invocations` projection; `project.execution.updated` and `snapshot_required` trigger REST refetches for the list and selected transcript instead of creating browser-only invocation rows.

The cinematic feedback model is separate from whichever invocation is selected in that rail. Only the latest running `dashboard_reply` or `worker_reply` for the resolved Project Manager preset is eligible, with `startedAt` and invocation id providing deterministic precedence. The model loads the persisted transcript through the existing invocation-message endpoint and exposes only non-empty normalized assistant prose. User/system turns, injected context, reasoning, tool arguments, and tool output are never promoted into stage copy.

Logical tool activity is deduplicated by normalized `metadata.toolCallId`; a stable message id is the fallback only when no call id exists. The frontend refreshes this projection when the active invocation or its `messageCount`, `lastMessageAt`, or `updatedAt` changes, preserves same-invocation feedback during refresh, and aborts or generation-invalidates stale work after project/invocation changes. Terminal or missing invocations clear the feedback. A transcript request failure remains a local, non-fatal state and does not replace the normal chat transcript or make unrelated work foreground activity.

Startup recovery reconciles stale workflow and provider rows from durable task-run, sprint-run, dispatch, process, and Docker-container evidence. Preparation-only rows can fail without provider linkage, terminal provider rows are reconciled without extending their usage window, and a recovered completed provider attempt may continue from its preserved workspace without a duplicate provider run.

## Focused verification
Expand Down
4 changes: 4 additions & 0 deletions docs/architecture/execution-invocation-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ This provides a clear audit log of the agent's work and prompt history separate
User-facing chat threads show up with `scope === "project"`, while agent background logs and execution runs appear with `scope === "connection"`.
The dashboard Chat -> Invocations rail renders only `execution_invocations` returned by the paginated `GET /api/projects/:projectId/execution/invocations` server endpoint. Invocation rows are created by the backend when the routed operation starts or is persisted. Sending a chat message still updates the thread transcript from the returned conversation message immediately, but the invocation rail waits for the persisted backend invocation row instead of inserting a frontend-only optimistic invocation placeholder. `project.execution.updated` and `snapshot_required` are invalidation signals: Chat refetches the authoritative invocation page and selected transcript instead of accepting or manufacturing invocation rows from realtime payloads in the browser.

The 3D Chat cinematic feedback model is independent from the invocation selected in that rail. It considers only the latest running `dashboard_reply` or `worker_reply` owned by the resolved Project Manager preset, ordered by `startedAt` and then invocation id, and loads that invocation's persisted messages through the existing invocation-message endpoint. Its interim copy is restricted to non-empty normalized assistant prose; user/system turns, injected context, reasoning, tool arguments, and tool output never become stage prose. Tool activity is counted by normalized `metadata.toolCallId`, with the stable message id used only when no call id exists, so call/result pairs and repeated refreshes remain one logical count.

`useCinematicInvocationFeedback` refreshes when the foreground invocation changes or its `messageCount`, `lastMessageAt`, or `updatedAt` summary changes. Same-invocation copy remains visible during a refresh, while project/invocation changes abort and generation-invalidate older requests. Terminal or missing invocations clear the projection immediately. Transcript fetch errors stay local and non-fatal; they do not replace the normal chat transcript or activate unrelated project work.

The Chat -> Invocations detail view exposes same-session recovery actions for failed or cancelled planning invocations. **Restart** preserves the original terminal transcript, creates a new invocation row, and resends the full planning prompt while passing the terminal provider row's native session id as `continueSessionId` (Claude Code uses `--resume <nativeSessionId>`). **Continue** uses the same native-session resume path and asks the provider to finish the previous planning attempt, but the continuation prompt also embeds the original planning instructions so a provider fallback to a fresh session still has the full schema, sprint goal, and task-generation context. Docker-backed planning runs use a stable project/sprint snapshot workspace and preserve its paired provider runtime volume while the run is failed, cancelled, or incomplete. Restart and Continue reuse that workspace so provider-local session files remain available; fresh planning invocations in `REMOTE` git mode still refresh `origin` and build a new snapshot from `origin/<branch>`, using the explicit sprint feature branch when present or the effective runtime git default branch otherwise. Successful planning cleans up that workspace and paired runtime volume. The replacement invocation has its own provider usage trail; the terminal row remains immutable evidence of the quota/error/cancellation history. If Claude Code reports "No conversation found" during resume, Code UX retries once with a fresh Claude session and persists that fresh native session id rather than the rejected id.

Running invocations can also be cancelled from the same detail header. Cancellation is available for every running invocation type, not just planning. The dashboard posts to `/api/execution/invocations/:invocationId/cancel`; the server requests any registered active dispatch to stop, finds Docker containers by the existing `code-ux.session-id` label from the linked provider/task runtime, kills those containers, marks the provider usage row `cancelled`, and appends a system cancellation message to the invocation transcript. Provider finalizers check the current invocation state before writing terminal status so a cancelled row is not overwritten by a late provider failure while the process unwinds.
Expand Down
Loading
Loading