<script lang="ts">
import { ops, type Op } from "../../stores/ops.svelte.js";
import { ticker, formatElapsed } from "../../stores/elapsed.svelte.js";
import { workbench } from "../../stores/workbench.svelte.js";
import AppIcon from "../layout/AppIcon.svelte";
import { activityRows } from "../../stores/activity-rows.js";
let container: HTMLDivElement | undefined = $state();
/**
* Running at the top, longest-running first; everything finished below it,
* newest first and one row per repeated activity.
*
* There is no auto-scroll any more, and no scroll-to-bottom. Both existed
* because the panel appended, so the newest thing was at the bottom and had
* to be chased. It does not append: what is happening now is at the top and
* stays there, and a repeated operation updates its own row rather than
* adding one.
*/
const rows = $derived(activityRows(ops.ops));
function clear() {
ops.clearFinished();
}
/**
* The clock, watched while anything here is running.
*
* One watch for the whole panel rather than one per row: the ticker counts
* watchers, and six running operations should tick once, not six times.
*/
$effect(() => (rows.some((row) => row.running) ? ticker.watch() : undefined));
/// Live for a running row, fixed for a finished one.
function duration(op: Op): string {
return formatElapsed(
op.started_at,
op.ended_at,
op.status === "running" ? ticker.now : 0,
);
}
function kindLabel(kind: string): string {
switch (kind) {
case "git_scan":
return "scan";
case "git_mutation":
return "git";
case "sync":
return "sync";
case "file_operation":
return "file";
case "catalogue_scan":
return "catalogue";
case "command":
return "wsb";
default:
return kind;
}
}
function openOutput(opId: string, label: string) {
workbench.openOpOutput(opId, label);
}
// The ops store lifecycle is managed by App.svelte (connect on app mount,
// disconnect on app destroy). ActivityPanel does not own the connection.
</script>
<div class="activity-toolbar">
<span class="toolbar-title">
{#if ops.active.length > 0}
<span class="running-badge">{ops.active.length} running</span>
{:else}
<span class="idle-label">Idle</span>
{/if}
</span>
<button class="toolbar-btn" onclick={clear} title="Clear completed operations">Clear</button>
</div>
<div class="activity-body" bind:this={container}>
{#if rows.length === 0}
<div class="activity-empty">No background operations recorded yet.</div>
{:else}
<!-- Keyed by the ROW, not the operation: a repeated activity keeps the
same key, so this updates a node in place instead of adding one. -->
{#each rows as row (row.key)}
{@const op = row.op}
<div class="op-row op-{op.status}" class:op-repeated={row.count > 1}>
<span class="op-icon">
{#if op.status === "running"}
<span class="spin"><AppIcon name="refresh" size={11} /></span>
{:else if op.status === "completed"}
<AppIcon name="circle-check" size={11} />
{:else}
<AppIcon name="circle-x" size={11} />
{/if}
</span>
<span class="op-kind">{kindLabel(op.kind)}</span>
<!-- Where it is running. A row for a terminal is not the daemon's own
work: it will not stop when this page is closed, and the window it
is in is where its prompt comes back. Marking it is what keeps
that from reading as a defect. -->
{#if op.started_from === "command-line"}
<span class="op-where" title="running in a terminal, not started here">
<AppIcon name="square-terminal" size={10} />
</span>
{/if}
<span class="op-label">{row.title}</span>
<!-- What this row stands for. A count instead of N identical rows, and
a separate count of the ones that FAILED — a row that reported
only its latest outcome would hide a failure behind the next
success. -->
{#if row.count > 1}
<!-- `×N` is a multiplication sign, read as a word: "times four". Not
a sign standing in for an icon, and an icon here would say
nothing. -->
<span class="op-repeat" title="{row.count} runs of this activity">×{row.count}</span>
{/if}
{#if row.failed > 0 && op.status !== "failed"}
<span
class="op-repeat op-repeat-failed"
title="{row.failed} of these runs failed"
>{row.failed} failed</span>
{/if}
{#if op.status === "running" && op.progress > 0}
<span class="op-progress-bar">
<span
class="op-progress-fill"
style="width: {Math.round(op.progress * 100)}%"
></span>
</span>
<span class="op-pct">{Math.round(op.progress * 100)}%</span>
{/if}
{#if op.status === "failed" && op.error}
<span class="op-error" title={op.error}>{op.error}</span>
{/if}
{#if op.has_output}
<button class="op-action" onclick={() => openOutput(op.id, op.label)}>
output
</button>
{/if}
<span class="op-duration">{duration(op)}</span>
</div>
{/each}
{/if}
</div>
<style>
.activity-toolbar {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
padding: 0 var(--wb-spacing-12);
height: var(--wsb-row-height-compact);
flex-shrink: 0;
border-bottom: 1px solid var(--vscode-widget-border);
background: var(--vscode-sideBar-background);
}
.toolbar-title {
flex: 1;
font-size: var(--wb-font-size-11);
}
.running-badge {
color: var(--vscode-testing-iconQueued, #ccb233);
font-weight: 600;
}
.idle-label {
color: var(--vscode-disabledForeground);
}
.toolbar-btn {
font-size: var(--wb-font-size-11);
color: var(--vscode-disabledForeground);
padding: var(--wb-spacing-1) var(--wb-spacing-6);
border-radius: var(--wsb-radius-sm);
border: 1px solid transparent;
}
.toolbar-btn:hover {
background: var(--vscode-list-hoverBackground);
color: var(--vscode-editor-foreground);
border-color: var(--vscode-widget-border);
}
.activity-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--wb-spacing-4) 0;
font-family: var(--wsb-font-mono, monospace);
font-size: var(--wb-font-size-11);
line-height: 1.5;
}
.activity-empty {
padding: var(--wb-spacing-12) var(--wb-spacing-16);
color: var(--vscode-disabledForeground);
font-family: var(--wsb-font-sans);
font-size: var(--wb-font-size-12);
}
.op-row {
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
padding: var(--wb-spacing-1) var(--wb-spacing-12);
min-height: var(--wb-control-size-18);
}
.op-row:hover {
background: var(--vscode-list-hoverBackground);
}
.op-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.op-running .op-icon {
color: var(--vscode-testing-iconQueued, #ccb233);
}
.op-completed .op-icon {
color: var(--vscode-testing-iconPassed, #73c991);
}
.op-failed .op-icon {
color: var(--vscode-errorForeground);
}
.op-where {
display: inline-flex;
align-items: center;
opacity: 0.6;
flex-shrink: 0;
}
.op-kind {
font-size: var(--wb-font-size-10);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
flex-shrink: 0;
min-width: var(--wb-control-size-40);
color: var(--vscode-disabledForeground);
}
.op-running .op-kind {
color: var(--vscode-testing-iconQueued, #ccb233);
}
.op-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--vscode-editor-foreground);
}
.op-failed .op-label {
color: var(--vscode-disabledForeground);
text-decoration: line-through;
}
.op-progress-bar {
width: var(--wb-control-size-60);
height: var(--wb-control-size-4);
border-radius: var(--wb-corner-radius-2);
background: var(--vscode-list-hoverBackground);
flex-shrink: 0;
overflow: hidden;
}
.op-progress-fill {
display: block;
height: 100%;
background: var(--vscode-testing-iconQueued, #ccb233);
border-radius: var(--wb-corner-radius-2);
transition: width 0.2s ease;
}
.op-pct {
flex-shrink: 0;
font-size: var(--wb-font-size-10);
color: var(--vscode-disabledForeground);
font-variant-numeric: tabular-nums;
min-width: var(--wb-control-size-28);
text-align: right;
}
/* A count, not an alarm: repetition is ordinary and should read as quiet. */
.op-repeat {
color: var(--vscode-descriptionForeground);
font-size: var(--wb-font-size-10);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
/* Except when some of them failed, which is the one thing collapsing could
have hidden. */
.op-repeat-failed {
color: var(--vscode-errorForeground);
}
.op-error {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--vscode-errorForeground);
font-size: var(--wb-font-size-10);
}
.op-duration {
flex-shrink: 0;
font-size: var(--wb-font-size-10);
color: var(--vscode-disabledForeground);
font-variant-numeric: tabular-nums;
margin-left: auto;
}
.op-action {
flex-shrink: 0;
font-size: var(--wb-font-size-10);
padding: 0 var(--wb-spacing-6);
border-radius: var(--wsb-radius-sm);
border: 1px solid var(--vscode-widget-border);
color: var(--vscode-disabledForeground);
background: none;
cursor: pointer;
}
.op-action:hover {
color: var(--vscode-editor-foreground);
background: var(--vscode-list-hoverBackground);
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin {
display: inline-flex;
animation: spin 1.2s linear infinite;
}
</style>