wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
<script lang="ts">
  import { analytics } from "../../stores/analytics.svelte.js";
  import { placeLabel } from "../../utils/places.js";
  import LoadingSpinner from "../layout/LoadingSpinner.svelte";

  const MAX_PROJECTS = 15;
  const BAR_HEIGHT = 20;
  const ROW_GAP = 4;
  const LABEL_WIDTH = 140;

  /** One bar: a place sessions ran, or the places folded into "Other". */
  interface Row {
    key: string;
    label: string;
    /** The project a click filters by; null for "Other" and outside `$PRJ`. */
    project: string | null;
    sessions: number;
    messages: number;
    agents: Record<string, number>;
  }

  const rows = $derived.by((): Row[] => {
    const places = analytics.projects?.projects;
    if (!places || places.length === 0) return [];

    const sorted = [...places]
      .sort((a, b) => b.messages - a.messages)
      .map((place) => ({
        key: placeLabel(place),
        label: placeLabel(place),
        project: place.project,
        sessions: place.sessions,
        messages: place.messages,
        agents: place.agents,
      }));

    if (sorted.length <= MAX_PROJECTS) return sorted;

    const top = sorted.slice(0, MAX_PROJECTS);
    const rest = sorted.slice(MAX_PROJECTS);
    const other: Row = {
      key: "other",
      label: `Other (${rest.length})`,
      project: null,
      sessions: rest.reduce((s, p) => s + p.sessions, 0),
      messages: rest.reduce((s, p) => s + p.messages, 0),
      agents: {},
    };
    return [...top, other];
  });

  const maxMessages = $derived(
    rows.length > 0
      ? Math.max(...rows.map((r) => r.messages), 1)
      : 1,
  );

  function barWidth(messages: number): number {
    return (messages / maxMessages) * 100;
  }

  function truncateName(name: string, max: number): string {
    if (name.length <= max) return name;
    return name.slice(0, max - 1) + "\u2026";
  }

  /** A place's bar filters by its project: analytics narrow by project. */
  function handleClick(row: Row) {
    if (row.project === null) return;
    analytics.setProject(row.project);
  }

  let tooltip = $state<{
    x: number;
    y: number;
    text: string;
  } | null>(null);

  function handleHover(
    e: MouseEvent,
    project: Row,
  ) {
    const rect = (
      e.currentTarget as HTMLElement
    ).getBoundingClientRect();
    const agents = Object.entries(project.agents)
      .sort(([, a], [, b]) => b - a)
      .map(([name, count]) => `${name}: ${count}`)
      .join(", ");
    const parts = [
      `${project.messages.toLocaleString()} messages`,
      `${project.sessions} sessions`,
    ];
    if (agents) parts.push(agents);
    tooltip = {
      x: rect.left + rect.width / 2,
      y: rect.top - 4,
      text: parts.join(" | "),
    };
  }

  function handleLeave() {
    tooltip = null;
  }
</script>

<div class="breakdown-container">
  <div class="breakdown-header">
    <h3 class="chart-title">Projects</h3>
    {#if rows.length > 0}
      <span class="count">{analytics.projects?.projects.length ?? 0} total</span>
    {/if}
  </div>

  {#if !analytics.loaded.projects || analytics.loading.projects}
    <LoadingSpinner label="Loading projects" />
  {:else if analytics.errors.projects}
    <div class="error">
      {analytics.errors.projects}
      <button
        class="retry-btn"
        onclick={() => analytics.fetchProjects()}
      >
        Retry
      </button>
    </div>
  {:else if rows.length > 0}
    <div class="bar-list">
      {#each rows as project, i}
        <!-- svelte-ignore a11y_click_events_have_key_events -->
        <!-- svelte-ignore a11y_no_static_element_interactions -->
        <div
          class="bar-row"
          class:clickable={project.project !== null}
          class:selected={project.project !== null && analytics.project === project.project}
          class:dimmed={analytics.project !== "" && analytics.project !== project.project}
          onclick={() => handleClick(project)}
          onmouseenter={(e) => handleHover(e, project)}
          onmouseleave={handleLeave}
        >
          <span class="project-name" title={project.label}>
            {truncateName(project.label, 24)}
          </span>
          <div class="bar-track">
            <div
              class="bar-fill"
              style="width: {barWidth(project.messages)}%"
            ></div>
          </div>
          <span class="bar-value">
            {project.messages.toLocaleString()}
          </span>
        </div>
      {/each}
    </div>

    {#if tooltip}
      <div
        class="tooltip"
        style="left: {tooltip.x}px; top: {tooltip.y}px;"
      >
        {tooltip.text}
      </div>
    {/if}
  {:else}
    <div class="empty">No project data</div>
  {/if}
</div>

<style>
  .breakdown-container {
    position: relative;
    flex: 1;
  }

  .breakdown-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: var(--wb-spacing-8);
  }

  .chart-title {
    font-size: var(--wb-font-size-12);
    font-weight: 600;
    color: var(--vscode-editor-foreground);
  }

  .count {
    font-size: var(--wb-font-size-10);
    color: var(--vscode-disabledForeground);
  }

  .bar-list {
    display: flex;
    flex-direction: column;
    gap: var(--wb-spacing-3);
  }

  .bar-row {
    display: flex;
    align-items: center;
    gap: var(--wb-spacing-8);
    padding: var(--wb-spacing-2) var(--wb-spacing-4);
    border-radius: var(--wsb-radius-sm);
    transition: background 0.1s;
  }

  .bar-row.clickable {
    cursor: pointer;
  }

  .bar-row.clickable:hover {
    background: var(--vscode-list-hoverBackground);
  }

  .bar-row.selected {
    background: color-mix(
      in srgb, var(--vscode-focusBorder) 12%, transparent
    );
  }

  .bar-row.dimmed {
    opacity: 0.35;
  }

  .bar-row.dimmed:hover {
    opacity: 0.7;
  }

  .project-name {
    flex-shrink: 0;
    width: 140px;
    font-size: var(--wb-font-size-11);
    color: var(--vscode-foreground);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .bar-track {
    flex: 1;
    height: var(--wb-control-size-14);
    background: var(--vscode-input-background);
    border-radius: var(--wb-corner-radius-2);
    overflow: hidden;
  }

  .bar-fill {
    height: 100%;
    background: var(--vscode-focusBorder);
    border-radius: var(--wb-corner-radius-2);
    min-width: var(--wb-control-size-2);
  }

  .bar-value {
    flex-shrink: 0;
    width: var(--wb-control-size-52);
    text-align: right;
    font-size: var(--wb-font-size-10);
    font-family: var(--wsb-font-mono);
    color: var(--vscode-disabledForeground);
  }

  .tooltip {
    position: fixed;
    transform: translateX(-50%) translateY(-100%);
    padding: var(--wb-spacing-4) var(--wb-spacing-8);
    background: var(--vscode-editor-foreground);
    color: var(--vscode-editor-background);
    font-size: var(--wb-font-size-10);
    border-radius: var(--wsb-radius-sm);
    white-space: nowrap;
    pointer-events: none;
    z-index: 100;
  }

  .empty {
    color: var(--vscode-disabledForeground);
    font-size: var(--wb-font-size-12);
    padding: var(--wb-spacing-24);
    text-align: center;
  }

  .error {
    color: var(--vscode-errorForeground);
    font-size: var(--wb-font-size-12);
    padding: var(--wb-spacing-12);
    display: flex;
    align-items: center;
    gap: var(--wb-spacing-8);
  }

  .retry-btn {
    padding: var(--wb-spacing-2) var(--wb-spacing-8);
    border: 1px solid currentColor;
    border-radius: var(--wsb-radius-sm);
    font-size: var(--wb-font-size-11);
    color: inherit;
    cursor: pointer;
  }
</style>