singularity-forge/src/resources/extensions/sf/dashboard-overlay.ts
2026-05-01 20:18:50 +02:00

896 lines
24 KiB
TypeScript

/**
* SF Dashboard Overlay
*
* Full-screen overlay showing auto-mode progress: milestone/slice/task
* breakdown, current unit, completed units, timing, and activity log.
* Toggled with Ctrl+Alt+G (⌃⌥G on macOS), Ctrl+Shift+G fallback,
* or opened from /sf status.
*/
import type { Theme } from "@singularity-forge/pi-coding-agent";
import {
Key,
matchesKey,
truncateToWidth,
visibleWidth,
} from "@singularity-forge/pi-tui";
import {
centerLine,
fitColumns,
formatDuration,
joinColumns,
padRight,
STATUS_COLOR,
STATUS_GLYPH,
} from "../shared/mod.js";
import {
getWorkerBatches,
hasActiveWorkers,
} from "../subagent/worker-registry.js";
import { getAutoDashboardData } from "./auto.js";
import type { AutoDashboardData } from "./auto-dashboard.js";
import { estimateTimeRemaining } from "./auto-dashboard.js";
import { runEnvironmentChecks } from "./doctor-environment.js";
import { loadFile } from "./files.js";
import {
aggregateByModel,
aggregateByPhase,
aggregateBySlice,
aggregateCacheHitRate,
formatCost,
formatCostProjection,
formatTokenCount,
getLedger,
getProjectTotals,
} from "./metrics.js";
import { resolveMilestoneFile } from "./paths.js";
import { loadEffectiveSFPreferences } from "./preferences.js";
import { computeProgressScore } from "./progress-score.js";
import { getMilestoneSlices, getSliceTasks, isDbAvailable } from "./sf-db.js";
import { formattedShortcutPair } from "./shortcut-defs.js";
import { deriveState } from "./state.js";
import { getActiveWorktreeName } from "./worktree-command.js";
function unitLabel(type: string): string {
switch (type) {
case "discuss-milestone":
case "discuss-slice":
return "Discuss";
case "research-milestone":
return "Research";
case "plan-milestone":
return "Plan";
case "research-slice":
return "Research";
case "plan-slice":
return "Plan";
case "execute-task":
return "Execute";
case "complete-slice":
return "Complete";
case "reassess-roadmap":
return "Reassess";
case "triage-captures":
return "Triage";
case "quick-task":
return "Quick Task";
case "replan-slice":
return "Replan";
case "custom-step":
return "Workflow Step";
default:
return type;
}
}
export class SFDashboardOverlay {
private tui: { requestRender: () => void };
private theme: Theme;
private onClose: () => void;
private cachedWidth?: number;
private cachedLines?: string[];
private refreshTimer: ReturnType<typeof setInterval>;
private scrollOffset = 0;
private dashData: AutoDashboardData;
private milestoneData: MilestoneView | null = null;
private loading = true;
private loadedDashboardIdentity?: string;
private refreshInFlight: Promise<void> | null = null;
private disposed = false;
private resizeHandler: (() => void) | null = null;
constructor(
tui: { requestRender: () => void },
theme: Theme,
onClose: () => void,
) {
this.tui = tui;
this.theme = theme;
this.onClose = onClose;
this.dashData = getAutoDashboardData();
// Invalidate cache on terminal resize
this.resizeHandler = () => {
if (this.disposed) return;
this.invalidate();
this.tui.requestRender();
};
process.stdout.on("resize", this.resizeHandler);
this.scheduleRefresh(true);
this.refreshTimer = setInterval(() => {
this.scheduleRefresh();
}, 10_000);
}
private scheduleRefresh(initial = false): void {
if (this.refreshInFlight || this.disposed) return;
this.refreshInFlight = this.refreshDashboard(initial).finally(() => {
this.refreshInFlight = null;
});
}
private computeDashboardIdentity(dashData: AutoDashboardData): string {
const base = dashData.basePath || process.cwd();
const currentUnit = dashData.currentUnit
? `${dashData.currentUnit.type}:${dashData.currentUnit.id}:${dashData.currentUnit.startedAt}`
: "-";
return [
base,
dashData.active ? "1" : "0",
dashData.paused ? "1" : "0",
currentUnit,
].join("|");
}
private async refreshDashboard(initial = false): Promise<void> {
if (this.disposed) return;
this.dashData = getAutoDashboardData();
const nextIdentity = this.computeDashboardIdentity(this.dashData);
if (initial || nextIdentity !== this.loadedDashboardIdentity) {
const loaded = await this.loadData();
if (this.disposed) return;
if (loaded) {
this.loadedDashboardIdentity = nextIdentity;
}
}
if (initial) {
this.loading = false;
}
this.invalidate();
this.tui.requestRender();
}
private async loadData(): Promise<boolean> {
const base = this.dashData.basePath || process.cwd();
try {
const state = await deriveState(base);
if (!state.activeMilestone) {
this.milestoneData = null;
return true;
}
const mid = state.activeMilestone.id;
const view: MilestoneView = {
id: mid,
title: state.activeMilestone.title,
slices: [],
phase: state.phase,
progress: {
milestones: {
total: state.progress?.milestones.total ?? state.registry.length,
done:
state.progress?.milestones.done ??
state.registry.filter((entry) => entry.status === "complete")
.length,
},
},
};
const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
const _roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
// Normalize slices from DB
type NormSlice = {
id: string;
done: boolean;
title: string;
risk: string;
};
let normSlices: NormSlice[] = [];
if (isDbAvailable()) {
normSlices = getMilestoneSlices(mid).map((s) => ({
id: s.id,
done: s.status === "complete",
title: s.title,
risk: s.risk || "medium",
}));
}
for (const s of normSlices) {
const sliceView: SliceView = {
id: s.id,
title: s.title,
done: s.done,
risk: s.risk,
active: state.activeSlice?.id === s.id,
tasks: [],
};
if (sliceView.active) {
// Normalize tasks from DB
if (isDbAvailable()) {
const dbTasks = getSliceTasks(mid, s.id);
sliceView.taskProgress = {
done: dbTasks.filter(
(t) => t.status === "complete" || t.status === "done",
).length,
total: dbTasks.length,
};
for (const t of dbTasks) {
sliceView.tasks.push({
id: t.id,
title: t.title,
done: t.status === "complete" || t.status === "done",
active: state.activeTask?.id === t.id,
});
}
}
}
view.slices.push(sliceView);
}
this.milestoneData = view;
return true;
} catch {
// Don't crash the overlay
return false;
}
}
handleInput(data: string): void {
if (
matchesKey(data, Key.escape) ||
matchesKey(data, Key.ctrl("c")) ||
matchesKey(data, Key.ctrlAlt("g")) ||
matchesKey(data, Key.ctrlShift("g"))
) {
this.dispose();
this.onClose();
return;
}
if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
this.scrollOffset++;
this.invalidate();
this.tui.requestRender();
return;
}
if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
this.invalidate();
this.tui.requestRender();
return;
}
if (data === "g") {
this.scrollOffset = 0;
this.invalidate();
this.tui.requestRender();
return;
}
if (data === "G") {
this.scrollOffset = 999;
this.invalidate();
this.tui.requestRender();
return;
}
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const content = this.buildContentLines(width);
const viewportHeight = Math.max(
5,
process.stdout.rows ? process.stdout.rows - 8 : 24,
);
const chromeHeight = 2;
const visibleContentRows = Math.max(1, viewportHeight - chromeHeight);
const maxScroll = Math.max(0, content.length - visibleContentRows);
this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
const visibleContent = content.slice(
this.scrollOffset,
this.scrollOffset + visibleContentRows,
);
const lines = this.wrapInBox(visibleContent, width);
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
private wrapInBox(inner: string[], width: number): string[] {
const th = this.theme;
const border = (s: string) => th.fg("borderAccent", s);
const innerWidth = width - 4;
const lines: string[] = [];
lines.push(border("╭" + "─".repeat(width - 2) + "╮"));
for (const line of inner) {
const truncated = truncateToWidth(line, innerWidth);
const padWidth = Math.max(0, innerWidth - visibleWidth(truncated));
lines.push(
border("│") +
" " +
truncated +
" ".repeat(padWidth) +
" " +
border("│"),
);
}
lines.push(border("╰" + "─".repeat(width - 2) + "╯"));
return lines;
}
private buildContentLines(width: number): string[] {
const th = this.theme;
const shellWidth = width - 4;
const contentWidth = Math.min(shellWidth, 128);
const sidePad = Math.max(0, Math.floor((shellWidth - contentWidth) / 2));
const leftMargin = " ".repeat(sidePad);
const lines: string[] = [];
const row = (content = ""): string => {
const truncated = truncateToWidth(content, contentWidth);
return leftMargin + padRight(truncated, contentWidth);
};
const blank = () => row("");
const hr = () => row(th.fg("dim", "─".repeat(contentWidth)));
const centered = (content: string) =>
row(centerLine(content, contentWidth));
const title = th.fg("accent", th.bold("SF Dashboard"));
const isRemote = !!this.dashData.remoteSession;
const status = this.dashData.active
? `${Date.now() % 2000 < 1000 ? th.fg("success", "●") : th.fg("dim", "○")} ${th.fg("success", "AUTO")}`
: this.dashData.paused
? th.fg("warning", "⏸ PAUSED")
: isRemote
? `${Date.now() % 2000 < 1000 ? th.fg("success", "●") : th.fg("dim", "○")} ${th.fg("success", "AUTO")} ${th.fg("dim", `(PID ${this.dashData.remoteSession!.pid})`)}`
: th.fg("dim", "idle");
const worktreeName = getActiveWorktreeName();
const worktreeTag = worktreeName
? ` ${th.fg("warning", `${worktreeName}`)}`
: "";
let elapsedParts = "";
if (this.dashData.active || this.dashData.paused) {
// Guard: skip display when elapsed is zero or unreasonably large (>30 days)
const elapsed = this.dashData.elapsed;
elapsedParts =
elapsed > 0 && elapsed < 30 * 24 * 3600_000
? th.fg("dim", formatDuration(elapsed))
: "";
const eta = estimateTimeRemaining();
if (eta) elapsedParts += th.fg("dim", ` · ${eta}`);
} else if (isRemote) {
elapsedParts = th.fg(
"dim",
`since ${this.dashData.remoteSession!.startedAt.replace("T", " ").slice(0, 19)}`,
);
}
lines.push(
row(
joinColumns(
`${title} ${status}${worktreeTag}`,
elapsedParts,
contentWidth,
),
),
);
// Progress score — traffic light indicator (#1221)
if (this.dashData.active || this.dashData.paused) {
const progressScore = computeProgressScore();
const progressIcon =
progressScore.level === "green"
? th.fg("success", "●")
: progressScore.level === "yellow"
? th.fg("warning", "●")
: th.fg("error", "●");
lines.push(
row(`${progressIcon} ${th.fg("text", progressScore.summary)}`),
);
// Show signal details when degraded — real-time visibility into what doctor found
if (progressScore.level !== "green" && progressScore.signals.length > 0) {
for (const signal of progressScore.signals) {
const prefix =
signal.kind === "positive"
? th.fg("success", " ✓")
: signal.kind === "negative"
? th.fg("error", " ✗")
: th.fg("dim", " ·");
lines.push(row(`${prefix} ${th.fg("dim", signal.label)}`));
}
}
}
lines.push(blank());
if (this.dashData.currentUnit) {
const cu = this.dashData.currentUnit;
const currentElapsed = th.fg(
"dim",
formatDuration(Date.now() - cu.startedAt),
);
lines.push(
row(
joinColumns(
`${th.fg("text", "Now")}: ${th.fg("accent", unitLabel(cu.type))} ${th.fg("text", cu.id)}`,
currentElapsed,
contentWidth,
),
),
);
lines.push(blank());
} else if (this.dashData.paused) {
lines.push(row(th.fg("dim", "/sf autonomous to resume")));
lines.push(blank());
} else if (isRemote) {
const rs = this.dashData.remoteSession!;
const unitDisplay =
rs.unitType === "starting" || rs.unitType === "resuming"
? rs.unitType
: `${unitLabel(rs.unitType)} ${rs.unitId}`;
lines.push(row(th.fg("text", `Remote session: ${unitDisplay}`)));
lines.push(blank());
} else {
lines.push(row(th.fg("dim", "No unit running · /sf autonomous to start")));
lines.push(blank());
}
// Parallel workers section — shows active subagent sessions
if (hasActiveWorkers()) {
lines.push(hr());
lines.push(row(th.fg("text", th.bold("Parallel Workers"))));
lines.push(blank());
const batches = getWorkerBatches();
for (const [batchId, workers] of batches) {
const _running = workers.filter((w) => w.status === "running").length;
const done = workers.filter((w) => w.status === "completed").length;
const failed = workers.filter((w) => w.status === "failed").length;
const total = workers[0]?.batchSize ?? workers.length;
lines.push(
row(
joinColumns(
` ${th.fg("accent", "⟐")} ${th.fg("text", `Batch ${batchId.slice(0, 8)}`)}`,
th.fg("dim", `${done + failed}/${total} done`),
contentWidth,
),
),
);
for (const w of workers) {
const icon =
w.status === "running"
? th.fg("accent", "▸")
: w.status === "completed"
? th.fg("success", "✓")
: th.fg("error", "✗");
const elapsed = th.fg(
"dim",
formatDuration(Date.now() - w.startedAt),
);
const taskPreview = truncateToWidth(
w.task,
Math.max(20, contentWidth - 30),
);
lines.push(
row(
joinColumns(
` ${icon} ${th.fg("text", w.agent)} ${th.fg("dim", taskPreview)}`,
elapsed,
contentWidth,
),
),
);
}
}
lines.push(blank());
}
// Pending captures badge — only shown when captures are waiting for triage
if (this.dashData.pendingCaptureCount > 0) {
const count = this.dashData.pendingCaptureCount;
lines.push(
row(
th.fg(
"warning",
`📌 ${count} pending capture${count === 1 ? "" : "s"} awaiting triage`,
),
),
);
lines.push(blank());
}
if (this.loading) {
lines.push(centered(th.fg("dim", "Loading dashboard…")));
return lines;
}
if (this.milestoneData) {
const mv = this.milestoneData;
lines.push(row(th.fg("text", th.bold(`${mv.id}: ${mv.title}`))));
lines.push(blank());
const totalSlices = mv.slices.length;
const doneSlices = mv.slices.filter((s) => s.done).length;
const totalMilestones = mv.progress.milestones.total;
const doneMilestones = mv.progress.milestones.done;
const activeSlice = mv.slices.find((s) => s.active);
lines.push(blank());
if (activeSlice?.taskProgress) {
lines.push(
row(
this.renderProgressRow(
"Tasks",
activeSlice.taskProgress.done,
activeSlice.taskProgress.total,
"accent",
contentWidth,
),
),
);
}
lines.push(
row(
this.renderProgressRow(
"Slices",
doneSlices,
totalSlices,
"success",
contentWidth,
),
),
);
lines.push(
row(
this.renderProgressRow(
"Milestones",
doneMilestones,
totalMilestones,
"warning",
contentWidth,
),
),
);
lines.push(blank());
for (const s of mv.slices) {
const sliceStatus = s.done ? "done" : s.active ? "active" : "pending";
const icon = th.fg(
STATUS_COLOR[sliceStatus],
STATUS_GLYPH[sliceStatus],
);
const titleColor = s.active ? "accent" : s.done ? "muted" : "dim";
const titleText = th.fg(titleColor, `${s.id}: ${s.title}`);
const risk = th.fg("dim", s.risk);
lines.push(
row(joinColumns(` ${icon} ${titleText}`, risk, contentWidth)),
);
if (s.active && s.tasks.length > 0) {
for (const t of s.tasks) {
const taskStatus = t.done
? "done"
: t.active
? "active"
: "pending";
const tIcon = th.fg(
STATUS_COLOR[taskStatus],
STATUS_GLYPH[taskStatus],
);
const tColor = t.active ? "warning" : t.done ? "muted" : "dim";
const tTitle = th.fg(tColor, `${t.id}: ${t.title}`);
lines.push(
row(
` ${tIcon} ${truncateToWidth(tTitle, contentWidth - 6)}`,
),
);
}
}
}
} else {
lines.push(centered(th.fg("dim", "No active milestone.")));
}
const ledger = getLedger();
if (ledger && ledger.units.length > 0) {
const totals = getProjectTotals(ledger.units);
lines.push(blank());
lines.push(hr());
lines.push(row(th.fg("text", th.bold("Cost & Usage"))));
lines.push(blank());
// Show cost or request count (for copilot/subscription users where cost is 0)
const costOrReqs =
totals.cost > 0
? `${th.fg("warning", formatCost(totals.cost))} total`
: `${th.fg("text", String(totals.apiRequests))} requests`;
lines.push(
row(
fitColumns(
[
costOrReqs,
`${th.fg("text", formatTokenCount(totals.tokens.total))} tokens`,
`${th.fg("text", String(totals.toolCalls))} tools`,
`${th.fg("text", String(totals.units))} units`,
],
contentWidth,
` ${th.fg("dim", "·")} `,
),
),
);
lines.push(
row(
fitColumns(
[
`${th.fg("dim", "in:")} ${th.fg("text", formatTokenCount(totals.tokens.input))}`,
`${th.fg("dim", "out:")} ${th.fg("text", formatTokenCount(totals.tokens.output))}`,
`${th.fg("dim", "cache-r:")} ${th.fg("text", formatTokenCount(totals.tokens.cacheRead))}`,
`${th.fg("dim", "cache-w:")} ${th.fg("text", formatTokenCount(totals.tokens.cacheWrite))}`,
],
contentWidth,
" ",
),
),
);
// Budget aggregate line — only when data exists
if (
totals.totalTruncationSections > 0 ||
totals.continueHereFiredCount > 0
) {
const budgetParts: string[] = [];
if (totals.totalTruncationSections > 0) {
budgetParts.push(
th.fg(
"warning",
`${totals.totalTruncationSections} sections truncated`,
),
);
}
if (totals.continueHereFiredCount > 0) {
budgetParts.push(
th.fg(
"error",
`${totals.continueHereFiredCount} continue-here fired`,
),
);
}
lines.push(row(budgetParts.join(` ${th.fg("dim", "·")} `)));
}
const phases = aggregateByPhase(ledger.units);
if (phases.length > 0) {
lines.push(blank());
lines.push(row(th.fg("dim", "By Phase")));
for (const p of phases) {
const pct =
totals.cost > 0 ? Math.round((p.cost / totals.cost) * 100) : 0;
const left = ` ${th.fg("text", p.phase.padEnd(14))}${th.fg("warning", formatCost(p.cost).padStart(8))}`;
const right = th.fg(
"dim",
`${String(pct).padStart(3)}% ${formatTokenCount(p.tokens.total)} tok ${p.units} units`,
);
lines.push(row(joinColumns(left, right, contentWidth)));
}
}
const slices = aggregateBySlice(ledger.units);
if (slices.length > 0) {
lines.push(blank());
lines.push(row(th.fg("dim", "By Slice")));
for (const s of slices) {
const pct =
totals.cost > 0 ? Math.round((s.cost / totals.cost) * 100) : 0;
const left = ` ${th.fg("text", s.sliceId.padEnd(14))}${th.fg("warning", formatCost(s.cost).padStart(8))}`;
const right = th.fg(
"dim",
`${String(pct).padStart(3)}% ${formatTokenCount(s.tokens.total)} tok ${formatDuration(s.duration)}`,
);
lines.push(row(joinColumns(left, right, contentWidth)));
}
}
// Cost projection — only when active milestone data is available
if (this.milestoneData) {
const mv = this.milestoneData;
const msTotalSlices = mv.slices.length;
const msDoneSlices = mv.slices.filter((s) => s.done).length;
const remainingCount = msTotalSlices - msDoneSlices;
const overlayPrefs = loadEffectiveSFPreferences()?.preferences;
const projLines = formatCostProjection(
slices,
remainingCount,
overlayPrefs?.budget_ceiling,
);
if (projLines.length > 0) {
lines.push(blank());
for (const line of projLines) {
const colored = line.toLowerCase().includes("ceiling")
? th.fg("warning", line)
: th.fg("dim", line);
lines.push(row(colored));
}
}
}
const models = aggregateByModel(ledger.units);
if (models.length >= 1) {
lines.push(blank());
lines.push(row(th.fg("dim", "By Model")));
for (const m of models) {
const pct =
totals.cost > 0 ? Math.round((m.cost / totals.cost) * 100) : 0;
const modelName = truncateToWidth(m.model, 38);
const ctxWindow =
m.contextWindowTokens !== undefined
? th.fg("dim", ` [${formatTokenCount(m.contextWindowTokens)}]`)
: "";
const left = ` ${th.fg("text", modelName.padEnd(38))}${th.fg("warning", formatCost(m.cost).padStart(8))}`;
const right =
th.fg("dim", `${String(pct).padStart(3)}% ${m.units} units`) +
ctxWindow;
lines.push(row(joinColumns(left, right, contentWidth)));
}
}
lines.push(blank());
lines.push(
row(
`${th.fg("dim", "avg/unit:")} ${th.fg("text", formatCost(totals.cost / totals.units))} ${th.fg("dim", "·")} ${th.fg("text", formatTokenCount(Math.round(totals.tokens.total / totals.units)))} tokens`,
),
);
// Cache hit rate
const cacheRate = aggregateCacheHitRate();
if (cacheRate > 0) {
lines.push(
row(
`${th.fg("dim", "cache hit rate:")} ${th.fg("text", `${cacheRate}%`)}`,
),
);
}
if (
this.dashData.rtkEnabled &&
this.dashData.rtkSavings &&
this.dashData.rtkSavings.commands > 0
) {
const rtk = this.dashData.rtkSavings;
lines.push(
row(
`${th.fg("dim", "rtk saved:")} ${th.fg("text", formatTokenCount(rtk.savedTokens))} ${th.fg("dim", `(${Math.round(rtk.savingsPct)}% · ${rtk.commands} cmd${rtk.commands === 1 ? "" : "s"})`)}`,
),
);
}
}
// Environment health section (#1221) — only show issues
const envResults = runEnvironmentChecks(
this.dashData.basePath || process.cwd(),
);
const envIssues = envResults.filter((r) => r.status !== "ok");
if (envIssues.length > 0) {
lines.push(blank());
lines.push(hr());
lines.push(row(th.fg("text", th.bold("Environment"))));
lines.push(blank());
for (const r of envIssues) {
const icon =
r.status === "error" ? th.fg("error", "✗") : th.fg("warning", "⚠");
lines.push(row(` ${icon} ${th.fg("text", r.message)}`));
if (r.detail) {
lines.push(row(th.fg("dim", ` ${r.detail}`)));
}
}
}
lines.push(blank());
lines.push(hr());
lines.push(
centered(
th.fg(
"dim",
`↑↓ scroll · g/G top/end · Esc/${formattedShortcutPair("dashboard")} close`,
),
),
);
return lines;
}
private renderProgressRow(
label: string,
done: number,
total: number,
color: "success" | "accent" | "warning",
width: number,
): string {
const th = this.theme;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
const labelWidth = 12;
const rightWidth = 14;
const gap = 2;
const labelText = truncateToWidth(label, labelWidth, "").padEnd(labelWidth);
const ratioText = `${done}/${total}`;
const rightText = `${String(pct).padStart(3)}% ${ratioText.padStart(rightWidth - 5)}`;
const barWidth = Math.max(12, width - labelWidth - rightWidth - gap * 2);
const filled = total > 0 ? Math.round((done / total) * barWidth) : 0;
const bar =
th.fg(color, "█".repeat(filled)) +
th.fg("dim", "░".repeat(Math.max(0, barWidth - filled)));
return `${th.fg("dim", labelText)}${" ".repeat(gap)}${bar}${" ".repeat(gap)}${th.fg("dim", rightText)}`;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
dispose(): void {
this.disposed = true;
clearInterval(this.refreshTimer);
if (this.resizeHandler) {
process.stdout.removeListener("resize", this.resizeHandler);
this.resizeHandler = null;
}
}
}
interface MilestoneView {
id: string;
title: string;
slices: SliceView[];
phase: string;
progress: {
milestones: {
total: number;
done: number;
};
};
}
interface SliceView {
id: string;
title: string;
done: boolean;
risk: string;
active: boolean;
tasks: TaskView[];
taskProgress?: { done: number; total: number };
}
interface TaskView {
id: string;
title: string;
done: boolean;
active: boolean;
}