fix(dashboard): card timer matches stats panel + resizable Changes diff modal

Two related dashboard fixes.

1. Card timer mismatch: the board card timer chip showed only workflow
   runtime (e.g. <1m on FN-2716) while the task detail Stats panel
   reported "Total execution time" of 7m+ for the same task. Cause —
   the slim board listing strips `task.log` to keep payloads small, so
   the card's client-side `[timing]` log scan returned 0. Now the slim
   path aggregates `[timing] … in <N>ms` durations server-side into a
   new `task.timedExecutionMs` field before stripping the log; the
   card prefers this aggregate, falling back to the client scan when
   the full log is loaded (TaskDetailModal). Wire payload stays slim.

2. View Changes diff modal: defaulted to `90vw × 80vh` and was not
   user-resizable. Switched to `min(95vw, 2200px) × min(90vh, ...)`
   default with `resize: both`, persisted via useModalResizePersist
   (`fusion:changes-diff-modal-size`). Mobile keeps fullscreen layout.
   Overlay dismiss switched to the shared `useOverlayDismiss` hook so
   resize-drags that release on the overlay don't close the modal.

Updated the diff modal's regression tests to match the new constraint
shape (still asserts max-height clamps to viewport via calc()).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-27 19:55:46 -07:00
parent 4e2caaf605
commit 8c554211f3
6 changed files with 134 additions and 31 deletions

View File

@@ -635,6 +635,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
attachments: slim ? undefined : entry.attachments,
comments: entry.comments,
log: slim ? [] : entry.log ?? [],
timedExecutionMs: slim ? this.computeTimedExecutionMs(entry.log) : undefined,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
columnMovedAt: entry.columnMovedAt,
@@ -835,9 +836,38 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "assigneeUserId",
"checkedOutBy", "checkedOutAt",
// `log` is fetched in slim mode so the server can aggregate
// `timedExecutionMs` from `[timing] … in <N>ms` entries before
// returning. The log itself is stripped from the response —
// see `listTasks()` slim post-processing.
"log",
].map((column) => `${prefix}${column}`).join(", ");
}
/**
* Sum the durations of all `[timing] … in <N>ms` (or `… after <N>ms`) log
* entries. Returns 0 when no timing entries are present.
*
* Mirrors the client-side `getTimedDurationMs` so slim board listings can
* report the same total-execution figure that the task detail Stats panel
* computes from the full log.
*/
private computeTimedExecutionMs(log: import("./types.js").TaskLogEntry[] | undefined): number {
if (!log || log.length === 0) return 0;
let total = 0;
for (const entry of log) {
const action = typeof entry.action === "string" ? entry.action : "";
const outcome = typeof entry.outcome === "string" ? entry.outcome : "";
if (!action.includes("[timing]") && !outcome.includes("[timing]")) continue;
const haystack = `${action}\n${outcome}`;
const match = haystack.match(/(\d+(?:\.\d+)?)ms\b/i);
if (!match) continue;
const ms = Number(match[1]);
if (Number.isFinite(ms)) total += ms;
}
return total;
}
private getTaskSelectClauseWithActivityLogLimit(limit: number): string {
const columns = [
"id", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep",
@@ -2255,6 +2285,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const rows = this.db.prepare(sql).all(...params);
const activeTasks = await Promise.all((rows as unknown as TaskRow[]).map(async (row) => {
const task = this.rowToTask(row);
// Slim path: aggregate the timed-execution total server-side, then
// strip the heavy log payload from the wire response. Without this
// the board card has no way to display the same total-execution
// figure that the task detail panel shows.
if (slim) {
task.timedExecutionMs = this.computeTimedExecutionMs(task.log);
task.log = [];
}
if (!slim || task.steps.length > 0) {
return task;
}
@@ -2383,6 +2423,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const activeMatches = await Promise.all(rows.map(async (row) => {
const task = this.rowToTask(row);
// Slim path mirrors `listTasks`: aggregate timed execution server-side
// before stripping the heavy log payload from the wire response.
if (slim) {
task.timedExecutionMs = this.computeTimedExecutionMs(task.log);
task.log = [];
}
if (task.steps.length > 0) {
return task;
}

View File

@@ -754,6 +754,12 @@ export interface Task {
/** Durable source provenance for the originating external issue. */
sourceIssue?: TaskSourceIssue;
log: TaskLogEntry[];
/** Pre-aggregated sum of `[timing] … in <N>ms` log durations, in milliseconds.
* Computed server-side so slim board listings can render the card timer
* without shipping the full agent log. The TaskDetailModal still derives
* this on the fly from `log`, so this field is only populated by the slim
* list path and may be omitted on the full-detail object. */
timedExecutionMs?: number;
/** Durable aggregate token usage totals for the task. Undefined when no usage has been recorded yet. */
tokenUsage?: TaskTokenUsage;
size?: "S" | "M" | "L";