# All files / src/components/sessions sessionsView.ts

100% Statements 169/169
98.38% Branches 61/62
100% Functions 10/10
100% Lines 169/169

Press n or j to go to the next uncovered block, b, p or k for the previous block.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307

    2x

    2x

    2x
    2x
    2x
    2x
    2x
    2x
    2x
    2x
    2x

    2x
    2x
    2x
    2x
    2x
    2x
    2x
    2x
    2x

    74x

    74x
    74x
    35x
    74x
    1x
    74x
    33x
    74x
    1x
    74x
    1x
    74x
    1x
    74x
    2x
    74x
    74x

    2x
    176x
    176x
    176x
    176x
    44x
    176x
    176x
    2x
    176x
    2x
    176x
    1x
    176x
    1x
    176x
    9x
    176x
    38x
    176x
    1x
    176x
    1x
    176x
    1x
    176x
    74x
    176x
    2x
    176x
    176x

    2x
    58x
    58x

    2x
    51x
    51x

    2x
    14x
    14x
    47x
    47x
    47x
    14x
    14x
    98x
    98x
    36x
    36x
    98x
    14x
    14x

    2x
    64x
    64x
    64x
    64x
    64x
    64x
    47x
    47x
    17x
    17x
    17x
    17x
    64x
    64x
    64x
    64x
    64x
    64x
    64x

    2x
    19x
    19x
    19x
    19x
    19x
    19x
    65x
    9x
    9x
    65x
    65x
    19x
    19x

    22x
    22x
    22x

    2x
    19x
    19x
    19x
    19x
    19x
    19x
    49x
    49x
    11x
    49x
    38x
    38x
    49x

    19x
    19x
    38x
    38x

    38x
    38x
    38x
    38x
    38x
    38x
    38x
    38x
    38x

    19x
    2x

    19x
    22x
    22x
    22x
    11x
    11x
    11x
    12x
    10x
    10x
    1x
    19x
    19x
     /**
     * Pure view-model helpers for the Sessions activity monitor.
     *
     * All branchy logic (status normalization, filtering, grouping, sorting, and
     * filter-chip derivation) lives here so it can be unit-tested directly. The
     * SessionsTable component is intentionally kept to straight-line JSX that
     * consumes these helpers.
     *
     * @module
     */

    import type { Environment, Session } from "../../hooks/types.js";
    import { capitalize } from "../../utils/sessionStatus.js";

    /**
     * A normalized status "tone": a small, stable set of buckets that both the raw
     * database session statuses and the live-event mapped statuses collapse into.
     * Drives colour, iconography, and filtering.
     *
     * This is intentionally distinct from {@link ../../utils/sessionStatus.js}'s
     * `sessionStatusStyle`, which the Coordination graph uses: that mapping
     * de-emphasizes finished sessions (completed -> neutral grey) and has no
     * `pending`/`paused`/endReason concepts. The Sessions activity monitor instead
     * wants a richer, success-positive palette (completed -> green) plus the extra
     * buckets that drive its status filter chips, so the two are kept separate by
     * design rather than unified.
     */
    export type StatusTone =
      | "running"
      | "idle"
      | "pending"
      | "success"
      | "error"
      | "paused"
      | "neutral";

    /** A human-readable description of a session's status. */
    export interface SessionStatusDescriptor {
      /** Normalized tone bucket used for colour/icon/filter. */
      tone: StatusTone;
      /** Capitalized display label (e.g. "Running", "Completed"). */
      label: string;
    }

    /** Tones considered "active" (the session is live, not finished or paused). */
    const ACTIVE_TONES: ReadonlySet<StatusTone> = new Set<StatusTone>(["running", "idle", "pending"]);

    /** Display order for status filter chips and grouping. */
    export const TONE_ORDER: readonly StatusTone[] = [
      "running",
      "idle",
      "pending",
      "success",
      "paused",
      "error",
      "neutral",
    ];

    /** Category labels for status filter chips (broader than per-status labels). */
    export const TONE_LABELS: Readonly<Record<StatusTone, string>> = {
      running: "Running",
      idle: "Idle",
      pending: "Pending",
      success: "Completed",
      paused: "Paused",
      error: "Failed",
      neutral: "Other",
    };

    /**
     * Describe a terminal ("stopped") session by its `endReason`.
     *
     * Sessions reach a generic `stopped` status via live status events; the
     * `endReason` distinguishes a clean completion from an error/kill.
     */
    function describeStopped(endReason: string | undefined): SessionStatusDescriptor {
      // Cases mirror grackle.END_REASON. A non-clean stop (killed/interrupted/
      // terminated/budget_exceeded/error) is an error tone so it lands in the
      // "Failed" filter bucket rather than the neutral "Stopped" fallback.
      switch (endReason) {
        case "completed":
          return { tone: "success", label: "Completed" };
        case "killed":
          return { tone: "error", label: "Killed" };
        case "interrupted":
          return { tone: "error", label: "Interrupted" };
        case "terminated":
          return { tone: "error", label: "Terminated" };
        case "budget_exceeded":
          return { tone: "error", label: "Budget exceeded" };
        case "error":
          return { tone: "error", label: "Error" };
        default:
          return { tone: "neutral", label: "Stopped" };
      }
    }

    /**
     * Map a session's raw status (and `endReason`) to a normalized tone + label.
     *
     * Handles both the raw database statuses returned by `ListSessions`
     * (`running`, `completed`, `failed`, ...) and the collapsed `stopped` status
     * produced by live status events (disambiguated via `endReason`).
     */
    export function describeSessionStatus(
      session: Pick<Session, "status" | "endReason">,
    ): SessionStatusDescriptor {
      switch (session.status) {
        case "running":
          return { tone: "running", label: "Running" };
        case "idle":
        case "waiting_input":
          return { tone: "idle", label: "Idle" };
        case "pending":
          return { tone: "pending", label: "Pending" };
        case "suspended":
          return { tone: "paused", label: "Suspended" };
        case "hibernating":
          return { tone: "paused", label: "Hibernating" };
        case "completed":
          return { tone: "success", label: "Completed" };
        case "failed":
          return { tone: "error", label: "Failed" };
        case "killed":
          return { tone: "error", label: "Killed" };
        case "interrupted":
          return { tone: "error", label: "Interrupted" };
        case "terminated":
          return { tone: "error", label: "Terminated" };
        case "stopped":
          return describeStopped(session.endReason);
        default:
          return { tone: "neutral", label: capitalize(session.status) || "Unknown" };
      }
    }

    /** Whether a tone represents a live (active) session. */
    export function isActiveTone(tone: StatusTone): boolean {
      return ACTIVE_TONES.has(tone);
    }

    /** Whether a session is currently active (live), by its normalized tone. */
    export function isActiveSession(session: Pick<Session, "status" | "endReason">): boolean {
      return isActiveTone(describeSessionStatus(session).tone);
    }

    /** A status filter selection: a specific tone, or `"all"` for no filter. */
    export type StatusFilter = StatusTone | "all";

    /** A single status filter chip with its display label and matching count. */
    export interface StatusChip {
      /** Filter value this chip selects. */
      value: StatusFilter;
      /** Display label. */
      label: string;
      /** Number of sessions matching this chip. */
      count: number;
    }

    /**
     * Build the ordered list of status filter chips for a set of sessions.
     *
     * Always begins with an "All" chip, followed by one chip per tone that is
     * actually present (in {@link TONE_ORDER}), each annotated with its count.
     * Chips for absent tones are omitted so the bar stays uncluttered.
     */
    export function buildStatusChips(sessions: readonly Session[]): StatusChip[] {
      const counts = new Map<StatusTone, number>();
      for (const session of sessions) {
        const { tone } = describeSessionStatus(session);
        counts.set(tone, (counts.get(tone) ?? 0) + 1);
      }
      const chips: StatusChip[] = [{ value: "all", label: "All", count: sessions.length }];
      for (const tone of TONE_ORDER) {
        const count = counts.get(tone);
        if (count !== undefined && count > 0) {
          chips.push({ value: tone, label: TONE_LABELS[tone], count });
        }
      }
      return chips;
    }

    /**
     * Whether a session matches a free-text query, checked against its prompt,
     * runtime, id, task id, and resolved environment name. An empty query matches
     * everything. Matching is case-insensitive.
     */
    export function sessionMatchesQuery(
      session: Session,
      query: string,
      environmentName: string,
    ): boolean {
      const trimmed = query.trim().toLowerCase();
      if (trimmed.length === 0) {
        return true;
      }
      const haystack = [
        session.prompt,
        session.runtime,
        session.id,
        session.taskId ?? "",
        environmentName,
      ]
        .join(" ")
        .toLowerCase();
      return haystack.includes(trimmed);
    }

    /**
     * Apply the status-tone filter and free-text query to a list of sessions.
     *
     * @param sessions - Sessions to filter.
     * @param status - Selected status filter, or `"all"`.
     * @param query - Free-text query (empty string matches all).
     * @param environmentNameById - Map of environment id to display name, used for
     *   both query matching and so a missing environment falls back to its id.
     */
    export function filterSessions(
      sessions: readonly Session[],
      status: StatusFilter,
      query: string,
      environmentNameById: ReadonlyMap<string, string>,
    ): Session[] {
      return sessions.filter((session) => {
        if (status !== "all" && describeSessionStatus(session).tone !== status) {
          return false;
        }
        const environmentName = environmentNameById.get(session.environmentId) ?? session.environmentId;
        return sessionMatchesQuery(session, query, environmentName);
      });
    }

    /** A group of sessions belonging to a single environment. */
    export interface SessionGroup {
      /** Environment id this group is keyed by. */
      environmentId: string;
      /** The environment, if it still exists (may be undefined for a gone env). */
      environment: Environment | undefined;
      /** Sessions in this group, newest first. */
      sessions: Session[];
      /** Number of active (live) sessions in this group. */
      activeCount: number;
      /** ISO timestamp of the most recently started session in this group. */
      latestStartedAt: string;
    }

    /** Compare two ISO timestamps for a newest-first sort. */
    function byNewest(a: string, b: string): number {
      return b.localeCompare(a);
    }

    /**
     * Group sessions by their environment, returning groups ready for display.
     *
     * Within each group, sessions are ordered newest-first. Groups are ordered so
     * that those with active sessions appear first, then by most-recent activity,
     * with a stable alphabetical tie-break on environment name. Environments with
     * no sessions are not represented (this is a session inventory, not an env
     * list); environments that no longer exist still get a group keyed by id.
     */
    export function groupSessionsByEnvironment(
      sessions: readonly Session[],
      environments: readonly Environment[],
    ): SessionGroup[] {
      const environmentById = new Map<string, Environment>(environments.map((e) => [e.id, e]));
      const byEnvironment = new Map<string, Session[]>();
      for (const session of sessions) {
        const existing = byEnvironment.get(session.environmentId);
        if (existing) {
          existing.push(session);
        } else {
          byEnvironment.set(session.environmentId, [session]);
        }
      }

      const groups: SessionGroup[] = [];
      for (const [environmentId, groupSessions] of byEnvironment) {
        const sorted = [...groupSessions].sort((a, b) => byNewest(a.startedAt, b.startedAt));
        const activeCount = sorted.filter((s) => isActiveSession(s)).length;
        // `sorted` is newest-first, so the head is the most recently started.
        const latestStartedAt = sorted[0]?.startedAt ?? "";
        groups.push({
          environmentId,
          environment: environmentById.get(environmentId),
          sessions: sorted,
          activeCount,
          latestStartedAt,
        });
      }

      const nameOf = (group: SessionGroup): string =>
        group.environment?.displayName ?? group.environmentId;

      return groups.sort((a, b) => {
        const aActive = a.activeCount > 0 ? 1 : 0;
        const bActive = b.activeCount > 0 ? 1 : 0;
        if (aActive !== bActive) {
          return bActive - aActive;
        }
        const byActivity = byNewest(a.latestStartedAt, b.latestStartedAt);
        if (byActivity !== 0) {
          return byActivity;
        }
        return nameOf(a).localeCompare(nameOf(b));
      });
    }