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
19 changes: 18 additions & 1 deletion dashboard/src/v2/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,11 @@ export const ChatPage: FunctionComponent = () => {
handleConfirm,
handleCancel,
execution,
executionLoading,
executionLoaded,
projectTasks,
projectTasksLoading,
projectTasksLoaded,
sprintKeyPrefix,
} = useChatPageData({ composerRef, messagesRef });

Expand All @@ -139,9 +143,22 @@ export const ChatPage: FunctionComponent = () => {
const widgetLiveData = useMemo(() => ({
projectId: selectedProject?.id ?? null,
projectTasks,
projectTasksLoading,
projectTasksLoaded,
execution,
executionLoading,
executionLoaded,
sprintKeyPrefix,
}), [execution, projectTasks, selectedProject?.id, sprintKeyPrefix]);
}), [
execution,
executionLoaded,
executionLoading,
projectTasks,
projectTasksLoaded,
projectTasksLoading,
selectedProject?.id,
sprintKeyPrefix,
]);

const handleRestartInvocation = useCallback(async (mode: InvocationRestartMode = "retry_full_prompt") => {
if (!selectedInvocation || selectedInvocation.status !== "failed" || restartingInvocation || cancellingInvocationId || resettingUsageLimitInvocationId) {
Expand Down
6 changes: 5 additions & 1 deletion dashboard/src/v2/hooks/use-chat-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const useChatPageData = (options?: { composerRef?: RefObject<HTMLTextArea
const { data: execution, loading: executionLoading } = useExecutions(selectedProject?.id || null);
const { data: effectiveSettings, loading: effectiveSettingsLoading } = useProjectEffectiveSettings(selectedProject?.id || null);
const { data: sprints } = useSprints(selectedProject?.id || null);
const { tasks: projectTasks } = useProjectTasks(
const { tasks: projectTasks, loading: projectTasksLoading, loaded: projectTasksLoaded } = useProjectTasks(
selectedProject?.id || null,
selectedProject ? [selectedProject] : [],
sprints,
Expand Down Expand Up @@ -273,7 +273,11 @@ export const useChatPageData = (options?: { composerRef?: RefObject<HTMLTextArea
invocationIndex: invocationData.invocationIndex,
selectedProject,
execution,
executionLoading,
executionLoaded: Boolean(selectedProject && !executionLoading && execution.projectId === selectedProject.id),
projectTasks,
projectTasksLoading,
projectTasksLoaded: Boolean(selectedProject && projectTasksLoaded),
sprintKeyPrefix: effectiveSettings?.settings?.git?.sprintKeyPrefix || "SPR",
feedback: threadData.feedback,
clearFeedback: threadData.clearFeedback,
Expand Down
20 changes: 19 additions & 1 deletion dashboard/src/v2/hooks/use-project-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { areTaskRecordListsEqual, shouldUseForegroundLoading } from "./project-r
interface UseProjectTasksResult {
tasks: Task[];
loading: boolean;
loaded: boolean;
error: string | null;
refresh: () => Promise<void>;
}
Expand All @@ -33,8 +34,10 @@ export function useProjectTasks(
): UseProjectTasksResult {
const [taskRecords, setTaskRecords] = useState<TaskRecord[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
const hasLoadedRef = useRef(false);
const loadedResourceKeyRef = useRef<string | null>(null);
const enabled = options?.enabled ?? true;
const resourceKey = projectId ? getTaskRecordsKey(projectId, sprintId) : null;

Expand All @@ -43,7 +46,9 @@ export function useProjectTasks(
setTaskRecords([]);
setError(null);
setLoading(false);
setLoaded(false);
hasLoadedRef.current = false;
loadedResourceKeyRef.current = null;
return;
}

Expand All @@ -68,6 +73,8 @@ export function useProjectTasks(
taskRecordsCache.set(key, nextTaskRecords);
setTaskRecords((current) => (areTaskRecordListsEqual(current, nextTaskRecords) ? current : nextTaskRecords));
hasLoadedRef.current = true;
loadedResourceKeyRef.current = key;
setLoaded(true);
setError(null);
} catch (fetchError) {
setError(fetchError instanceof Error ? fetchError.message : String(fetchError));
Expand All @@ -83,6 +90,11 @@ export function useProjectTasks(
if (resourceKey && taskRecordsCache.has(resourceKey)) {
setTaskRecords(taskRecordsCache.get(resourceKey)!);
hasLoadedRef.current = true;
loadedResourceKeyRef.current = resourceKey;
setLoaded(true);
} else {
loadedResourceKeyRef.current = null;
setLoaded(false);
}
void refreshInternal();
}, [enabled, projectId, resourceKey, sprintId, refreshInternal]);
Expand Down Expand Up @@ -139,5 +151,11 @@ export function useProjectTasks(
await refreshInternal({ silent: true });
}, [refreshInternal]);

return { tasks, loading, error, refresh };
return {
tasks,
loading,
loaded: Boolean(resourceKey && loaded && loadedResourceKeyRef.current === resourceKey),
error,
refresh,
};
}
13 changes: 12 additions & 1 deletion dashboard/src/v2/lib/chat-widget-view-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ export interface LivePlanningWidgetState {
export interface ChatWidgetLiveData {
projectId: string | null;
projectTasks?: Task[] | null;
projectTasksLoading?: boolean;
projectTasksLoaded?: boolean;
execution?: ExecutionDashboardSnapshot | null;
executionLoading?: boolean;
executionLoaded?: boolean;
sprintKeyPrefix?: string;
}

Expand Down Expand Up @@ -275,7 +279,14 @@ const buildLivePlanningWidgetState = (
fallbackPlanName: string,
liveData?: ChatWidgetLiveData,
): LivePlanningWidgetState | null => {
if (!liveData?.execution || !Array.isArray(liveData.projectTasks)) {
if (
!liveData?.execution
|| !Array.isArray(liveData.projectTasks)
|| liveData.executionLoading
|| liveData.projectTasksLoading
|| liveData.executionLoaded !== true
|| liveData.projectTasksLoaded !== true
) {
return null;
}

Expand Down
2 changes: 1 addition & 1 deletion docs-web/content/docs/user-dashboard-chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ To start a new thread, click **+ New thread**. To change the responding agent, o

Each post triggers a routed invocation: the dashboard records the request, dispatches it to the chosen provider via the worker assignment service (routed through the `dashboard_reply` invocation type), and streams the reply back into the thread.

Planning messages can include a rich sprint status card. When Code UX can match the message to live project data, the card is backed by the current task records and execution snapshot, so it updates as tasks move from queued to running, completed, failed, blocked, or quota-waiting. It shows the sprint key/name, request/task/run materialization, overall progress such as `0/7 · 0%`, queued task count, and a compact task list. If task or execution data has not materialized yet, the chat keeps the generic planning status card until live records are available.
Planning messages can include a rich sprint status card. When Code UX can match the message to loaded live project data, the card is backed by the current task records and execution snapshot, so it updates as tasks move from queued to running, completed, failed, blocked, or quota-waiting. It shows the sprint key/name, request/task/run materialization, overall progress such as `0/7 · 0%`, queued task count, and a compact task list. If either task records or the execution snapshot are still loading, the chat keeps the generic planning status card until both live records are available for the active project.

## Compacting a thread

Expand Down
2 changes: 1 addition & 1 deletion docs-web/user/dashboard/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ To start a new thread, click **+ New thread**. To change the responding agent, o

Each post triggers a routed invocation: the dashboard records the request, dispatches it to the chosen provider via the worker assignment service (routed through the `dashboard_reply` invocation type), and streams the reply back into the thread.

Planning messages can include a rich sprint status card. When Code UX can match the message to live project data, the card is backed by the current task records and execution snapshot, so it updates as tasks move from queued to running, completed, failed, blocked, or quota-waiting. It shows the sprint key/name, request/task/run materialization, overall progress such as `0/7 · 0%`, queued task count, and a compact task list. If task or execution data has not materialized yet, the chat keeps the generic planning status card until live records are available.
Planning messages can include a rich sprint status card. When Code UX can match the message to loaded live project data, the card is backed by the current task records and execution snapshot, so it updates as tasks move from queued to running, completed, failed, blocked, or quota-waiting. It shows the sprint key/name, request/task/run materialization, overall progress such as `0/7 · 0%`, queued task count, and a compact task list. If either task records or the execution snapshot are still loading, the chat keeps the generic planning status card until both live records are available for the active project.

## Compacting a thread

Expand Down
4 changes: 2 additions & 2 deletions docs/dashboard/design-system-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The chat and invocation design system for the Code UX dashboard defines the layo
- **System**: Rendered distinctly (e.g., dashed borders, monospaced headers, truncated views) to separate internal instructions from standard dialogue.
- **Tool Calls / Reasoning**: Presented as full-width, compact cards rather than standard bubbles to clearly differentiate them as structural operations or internal thoughts rather than user-facing dialogue.
- **Widgets**: specialized components (Routing, Planning, Container) embedded within the stream to provide rich status and execution context without cluttering the text transcript. They use a unified visual language (`ChatWidgetFrame`).
- **Planning status**: planning widgets prefer live project state when available. The view-model resolves a sprint from message metadata (`sprintId`, `sprintRunId`, or planning widget metadata) or from the active execution run, then combines project task records with the execution snapshot's dispatches and runtime events. The card shows sprint key/name, request/task/run materialization, a real `progressbar`, queued/completed counts, and per-task status labels. If task or execution sibling data has not loaded yet, the widget falls back to the generic planning card instead of rendering partial or invented progress.
- **Planning status**: planning widgets prefer live project state when available. The view-model resolves a sprint from message metadata (`sprintId`, `sprintRunId`, or planning widget metadata) or from the active execution run, then combines project task records with the execution snapshot's dispatches and runtime events. The card shows sprint key/name, request/task/run materialization, a real `progressbar`, queued/completed counts, and per-task status labels. Both project task records and the execution snapshot must report loaded for the active project before the live card is rendered; otherwise the widget falls back to the generic planning card instead of rendering partial or invented progress.
- **Reasoning turns**: internal thinking output renders as a dedicated `ReasoningWidget`, not as a generic assistant bubble and not as a tool-call widget. It keeps the text plain and whitespace-preserving, adds provider/model/timing/token context in the header, and collapses long content behind an expand/collapse button with `aria-expanded` and `aria-controls`.
- **Collapsed long content**: long reasoning stays readable by default through a short preview and expands in place. Short reasoning stays fully visible with no affordance churn, while the widget still keeps a stable region label for screen readers.
- **Assistant vs. tool-call vs. reasoning**: assistant bubbles remain the normal markdown transcript surface, tool-call widgets summarize structured input/output/status for operations, and reasoning widgets are reserved for transcripted internal deliberation so the live session can be inspected without flattening every turn into the same bubble style.
Expand Down Expand Up @@ -66,4 +66,4 @@ Agent replies are ordinary markdown; embedding a fenced block renders a designed
## Data Flow and Polling
- **Active Invocation Polling**: When active invocations exist (running or optimistic), the dashboard actively polls for updates. This polling relies on a stable derived key (`activeInvocationKey`) representing the set of active IDs to prevent unnecessary interval resets when non-ID metadata updates. Stale refreshes are prevented by verifying that both the active project and the selected invocation remain identical to when the polling cycle started.
- **Invocation Pagination**: The chat page calls the paginated invocation query with `limit=40` for the initial rail load. After more pages are loaded, live refreshes request the currently loaded window size so scroll-expanded history remains present while active invocation metadata updates.
- **Live Planning Widgets**: Thread and invocation bubbles receive the current project tasks and `ExecutionDashboardSnapshot` from existing hooks. Task refreshes follow project task realtime updates, and execution changes arrive through `project.execution.updated`; no extra polling endpoint is used for chat widgets. The widget delegates task phase derivation to the shared live task helpers so queued, running, completed, failed, blocked, quota, and provider-cap states match the Live page.
- **Live Planning Widgets**: Thread and invocation bubbles receive the current project tasks, task loading state, `ExecutionDashboardSnapshot`, and execution loading state from existing hooks. Task refreshes follow project task realtime updates, and execution changes arrive through `project.execution.updated`; no extra polling endpoint is used for chat widgets. The widget delegates task phase derivation to the shared live task helpers so queued, running, completed, failed, blocked, quota, and provider-cap states match the Live page.
35 changes: 35 additions & 0 deletions tests/dashboard/lib/chat-widget-view-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,11 @@ describe("Chat Widget View Models", () => {
const result = getChatWidgetData(message, {
projectId: "project-1",
projectTasks,
projectTasksLoading: false,
projectTasksLoaded: true,
execution: createExecution({ sprintRuns: [createExecution().sprintRuns[0]!, { ...createExecution().sprintRuns[0]!, id: "run-queued", status: "queued" }] }),
executionLoading: false,
executionLoaded: true,
sprintKeyPrefix: "SPR",
});

Expand Down Expand Up @@ -233,11 +237,15 @@ describe("Chat Widget View Models", () => {
createTask({ recordId: "task-2", id: "TASK-2", status: "completed", title: "Completed two" }),
createTask({ recordId: "task-3", id: "TASK-3", status: "in_progress", title: "Running task" }),
],
projectTasksLoading: false,
projectTasksLoaded: true,
execution: createExecution({
taskDispatches: [
createDispatch({ id: "dispatch-3", taskId: "task-3", taskKey: "TASK-3", status: "running", taskRunState: "RUNNING" }),
],
}),
executionLoading: false,
executionLoaded: true,
});

expect(result.liveStatus?.progressLabel).toBe("2/3 · 67%");
Expand All @@ -261,17 +269,44 @@ describe("Chat Widget View Models", () => {
createTask({ recordId: "task-blocked", id: "TASK-B", title: "Blocked task" }),
createTask({ recordId: "task-quota", id: "TASK-Q", title: "Quota task" }),
],
projectTasksLoading: false,
projectTasksLoaded: true,
execution: createExecution({
taskDispatches: [
createDispatch({ id: "dispatch-failed", taskId: "task-failed", taskKey: "TASK-F", status: "failed", taskRunState: "FAILED", finishedAt: "2026-03-10T12:05:00.000Z" }),
createDispatch({ id: "dispatch-blocked", taskId: "task-blocked", taskKey: "TASK-B", status: "blocked", taskRunState: "BLOCKED", finishedAt: "2026-03-10T12:06:00.000Z" }),
createDispatch({ id: "dispatch-quota", taskId: "task-quota", taskKey: "TASK-Q", status: "quota", taskRunState: "QUOTA", finishedAt: "2026-03-10T12:07:00.000Z" }),
],
}),
executionLoading: false,
executionLoaded: true,
});

expect(result.liveStatus?.tasks.map((task) => task.statusLabel)).toEqual(["Failed", "Blocked", "Quota wait"]);
});

it("keeps the generic planning fallback while execution and task data are still loading", () => {
const message = {
metadata: {
type: "planning",
status: "queued",
planName: "Sprint request",
sprintId: "sprint-1",
}
} as unknown as ChatMessageRecord;

const result = getChatWidgetData(message, {
projectId: "project-1",
projectTasks: [createTask()],
projectTasksLoading: true,
projectTasksLoaded: false,
execution: createExecution(),
executionLoading: false,
executionLoaded: true,
});

expect(result).toEqual({ type: "planning", status: "queued", planName: "Sprint request" });
});
});

describe("getInvocationWidgetData", () => {
Expand Down
Loading