From fef4a2420064d214e81bdf52b5862430185709f1 Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Tue, 16 Jun 2026 17:37:22 +0300 Subject: [PATCH 01/16] Hold the displayed order in the Members list --- src/main/processes/process-action-service.ts | 14 ++++++- .../components/processes/process-detail.tsx | 39 +++++++++++++++++-- src/renderer/domain/process-detail.ts | 23 +++++++++++ src/renderer/lib/format.ts | 4 +- tests/unit/format.test.ts | 4 ++ tests/unit/process-action-service.test.ts | 8 ++++ 6 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/main/processes/process-action-service.ts b/src/main/processes/process-action-service.ts index 33ec468..d6f35d7 100644 --- a/src/main/processes/process-action-service.ts +++ b/src/main/processes/process-action-service.ts @@ -82,6 +82,18 @@ function hasStableTargetIdentity(target: ProcessIdentity | undefined): boolean { return target?.startedAtStatus === FieldStatus.FIELD_STATUS_OK; } +/** + * True for MoStats itself: the main process or any of its direct child helpers + * (renderer, GPU, utility), so neither can be signaled into self-destabilizing + * the app. Helper PIDs differ from the main PID, but their parent is it. + */ +export function isSelfProcess(row: ProcessRow, selfPid: number): boolean { + if ((row.identity?.pid ?? 0) === selfPid) { + return true; + } + return row.statics?.parentStatus === FieldStatus.FIELD_STATUS_OK && row.statics.parentPid === selfPid; +} + /** * True for a session-critical process that must never be signaled: PID 0/1 * plus the {@link CRITICAL_PROCESS_NAMES} denylist, matched against both the @@ -123,7 +135,7 @@ export function disabledReasonFor( if (!hasStableTargetIdentity(target)) { return ActionDisabledReason.ACTION_DISABLED_REASON_UNSTABLE_IDENTITY; } - if ((row.identity?.pid ?? 0) === selfPid) { + if (isSelfProcess(row, selfPid)) { return ActionDisabledReason.ACTION_DISABLED_REASON_SELF; } if (isCriticalProcess(row)) { diff --git a/src/renderer/components/processes/process-detail.tsx b/src/renderer/components/processes/process-detail.tsx index cfeebf3..76fefb5 100644 --- a/src/renderer/components/processes/process-detail.tsx +++ b/src/renderer/components/processes/process-detail.tsx @@ -1,5 +1,5 @@ import { ChevronLeft, ChevronRight, Clock, Cpu, User } from "lucide-react"; -import { memo, useEffect, useRef, useState, type ReactNode } from "react"; +import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { cn } from "@/lib/utils"; import { UNAVAILABLE_TEXT, formatStartTime } from "@/lib/format"; @@ -10,6 +10,7 @@ import { ScrollFade } from "@/components/processes/scroll-fade"; import { ProcessIcon } from "@/components/processes/process-icon"; import { ProcessSortControl } from "@/components/processes/process-sort-control"; import { metricValueText, type SortMode } from "@/domain/process-list"; +import { pinMemberOrder } from "@/domain/process-detail"; import type { DetailField, DetailMember, @@ -150,6 +151,7 @@ export function ProcessDetailView({ members={detail.members} memberCount={detail.memberCount} total={detail.total} + resetKey={`${detail.pid}:${detail.totalSort}`} onOpenMember={onOpenMember} /> ) : ( @@ -343,17 +345,38 @@ function ScrollableValue({ * its own detail. Starts expanded; scrolls within a bounded box. */ function Members({ - members, + members: rankedMembers, memberCount, total, + resetKey, onOpenMember, }: { members: DetailMember[] memberCount: number total: DetailField + /** Changes when the drilled target or sort changes, dropping any stale pin. */ + resetKey: string onOpenMember: (pid: number, startedAtUnixMs?: number) => void }) { const [expanded, setExpanded] = useState(true); + const [pointerInside, setPointerInside] = useState(false); + const [focusInside, setFocusInside] = useState(false); + // The PID order last shown; the baseline the next pinned tick replays. + const pinnedPids = useRef([]); + + useEffect(() => { + pinnedPids.current = []; + }, [resetKey]); + + const pinActive = pointerInside || focusInside; + const members = useMemo( + () => (pinActive ? pinMemberOrder(rankedMembers, pinnedPids.current) : rankedMembers), + [pinActive, rankedMembers], + ); + + useEffect(() => { + pinnedPids.current = members.map((member) => member.pid); + }, [members]); return (
@@ -378,7 +401,17 @@ function Members({ -
    +
      setPointerInside(true)} + onPointerLeave={() => setPointerInside(false)} + onFocusCapture={() => setFocusInside(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setFocusInside(false); + } + }} + > {members.map((member) => (
    • diff --git a/src/renderer/domain/process-detail.ts b/src/renderer/domain/process-detail.ts index 0996993..1c195a5 100644 --- a/src/renderer/domain/process-detail.ts +++ b/src/renderer/domain/process-detail.ts @@ -255,3 +255,26 @@ export function buildProcessDetail(group: ProcessGroup, sort: SortMode, icons: I notResponding: group.notResponding, }; } + +/** + * Reorders members to match a previously rendered PID order, so the Members + * list can pin row positions while the pointer or focus is inside it (a live + * re-rank would move rows between aiming and clicking). Mirrors + * {@link pinGroupOrder}: only the order is held; member objects and their + * values are the fresh ones, new PIDs append, vanished PIDs drop out. + */ +export function pinMemberOrder(members: DetailMember[], pinnedPids: number[]): DetailMember[] { + if (pinnedPids.length === 0) { + return members; + } + + const rankByPid = new Map(pinnedPids.map((pid, index) => [pid, index] as const)); + const pinned: DetailMember[] = []; + const fresh: DetailMember[] = []; + for (const member of members) { + (rankByPid.has(member.pid) ? pinned : fresh).push(member); + } + pinned.sort((left, right) => (rankByPid.get(left.pid) ?? 0) - (rankByPid.get(right.pid) ?? 0)); + + return [...pinned, ...fresh]; +} diff --git a/src/renderer/lib/format.ts b/src/renderer/lib/format.ts index 8ac647e..87d50a4 100644 --- a/src/renderer/lib/format.ts +++ b/src/renderer/lib/format.ts @@ -168,7 +168,9 @@ export function formatCelsius(celsius: number): string { */ export function formatStartTime(epochMs: number): string { if (!Number.isFinite(epochMs) || epochMs <= 0) return UNAVAILABLE_TEXT; - return new Date(epochMs).toLocaleString(undefined, { + const date = new Date(epochMs); + if (Number.isNaN(date.getTime())) return UNAVAILABLE_TEXT; + return date.toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", diff --git a/tests/unit/format.test.ts b/tests/unit/format.test.ts index 3e35521..7f4e639 100644 --- a/tests/unit/format.test.ts +++ b/tests/unit/format.test.ts @@ -182,6 +182,10 @@ describe("formatStartTime", () => { expect(formatStartTime(Number.NaN)).toBe(UNAVAILABLE_TEXT); }); + it("is unavailable for an epoch beyond the JS Date range", () => { + expect(formatStartTime(8.7e15)).toBe(UNAVAILABLE_TEXT); + }); + it("renders a real epoch as a non-empty local string", () => { // The exact text is locale/timezone dependent, so assert it produced a // concrete (non-unavailable) string rather than a specific format. diff --git a/tests/unit/process-action-service.test.ts b/tests/unit/process-action-service.test.ts index c87ce1c..c9fcfb2 100644 --- a/tests/unit/process-action-service.test.ts +++ b/tests/unit/process-action-service.test.ts @@ -116,6 +116,14 @@ describe("disabledReasonFor", () => { .toBe(ActionDisabledReason.ACTION_DISABLED_REASON_SELF); }); + it("quit blocks a direct child helper of MoStats (SELF)", () => { + const helper = makeRow({ pid: SOME_PID, startedAtUnixMs: 1, commandName: "MoStats Helper", parentPid: selfPid }); + expect(disabledReasonFor(QUIT, helper, selfPid, makeTarget(SOME_PID, 1))) + .toBe(ActionDisabledReason.ACTION_DISABLED_REASON_SELF); + expect(disabledReasonFor(FORCE_QUIT, helper, selfPid, makeTarget(SOME_PID, 1))) + .toBe(ActionDisabledReason.ACTION_DISABLED_REASON_SELF); + }); + it("quit blocks a session-critical process (PROTECTED)", () => { const row = makeRow({ pid: 80, startedAtUnixMs: 1, commandName: "WindowServer" }); expect(disabledReasonFor(QUIT, row, selfPid, makeTarget(80, 1))) From eeca320854622a19b4c9b7dcf43059f04c1683fa Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Tue, 16 Jun 2026 19:34:36 +0300 Subject: [PATCH 02/16] Extract MemberRow component and implement order pinning for process lists --- .../components/processes/member-row.tsx | 70 +++++++ .../components/processes/process-detail.tsx | 66 +------ .../processes/process-explorer-view.tsx | 4 +- .../components/processes/process-list.tsx | 84 +++++--- .../components/processes/process-row.tsx | 187 +++++++++++++----- .../components/processes/use-order-pin.ts | 42 ++++ src/renderer/domain/process-detail.ts | 64 +++--- src/renderer/domain/process-list.ts | 36 ++-- tests/unit/process-list.test.ts | 42 ---- 9 files changed, 363 insertions(+), 232 deletions(-) create mode 100644 src/renderer/components/processes/member-row.tsx create mode 100644 src/renderer/components/processes/use-order-pin.ts diff --git a/src/renderer/components/processes/member-row.tsx b/src/renderer/components/processes/member-row.tsx new file mode 100644 index 0000000..1b2aa4e --- /dev/null +++ b/src/renderer/components/processes/member-row.tsx @@ -0,0 +1,70 @@ +import { memo } from "react"; + +import { cn } from "@/lib/utils"; +import { ProcessIcon } from "@/components/processes/process-icon"; +import { metricValueText } from "@/domain/process-list"; +import type { DetailMember } from "@/domain/process-detail"; + +/** + * One member-process row - icon, name, active-metric value - drillable into its + * own detail. Shared by the detail view's Members section and the inline + * expanded list under a grouped row, where `indented` sits the row under the + * parent icon and mutes its name to read as a child. Memoized field-wise so an + * unchanged member skips re-rendering across snapshot ticks. + */ +export const MemberRow = memo( + function MemberRow({ + member, + indented = false, + onOpen, + }: { + member: DetailMember + indented?: boolean + onOpen: (pid: number, startedAtUnixMs?: number) => void + }) { + return ( + + ); + }, + (prev, next) => + prev.onOpen === next.onOpen && + prev.indented === next.indented && + prev.member.pid === next.member.pid && + prev.member.startedAtUnixMs === next.member.startedAtUnixMs && + prev.member.name === next.member.name && + prev.member.iconPngBase64 === next.member.iconPngBase64 && + prev.member.metricState === next.member.metricState && + prev.member.metricText === next.member.metricText && + prev.member.notResponding === next.member.notResponding, +); diff --git a/src/renderer/components/processes/process-detail.tsx b/src/renderer/components/processes/process-detail.tsx index 76fefb5..73ceaf1 100644 --- a/src/renderer/components/processes/process-detail.tsx +++ b/src/renderer/components/processes/process-detail.tsx @@ -1,16 +1,18 @@ import { ChevronLeft, ChevronRight, Clock, Cpu, User } from "lucide-react"; -import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { cn } from "@/lib/utils"; import { UNAVAILABLE_TEXT, formatStartTime } from "@/lib/format"; import type { ActionState, ProcessActionKind } from "@/gen/process_explorer"; import { CopyButton, DisclosureContent } from "@/components/processes/disclosure"; +import { MemberRow } from "@/components/processes/member-row"; import { ProcessActions } from "@/components/processes/process-actions"; import { ScrollFade } from "@/components/processes/scroll-fade"; import { ProcessIcon } from "@/components/processes/process-icon"; import { ProcessSortControl } from "@/components/processes/process-sort-control"; +import { useOrderPin } from "@/components/processes/use-order-pin"; import { metricValueText, type SortMode } from "@/domain/process-list"; -import { pinMemberOrder } from "@/domain/process-detail"; +import { memberPid } from "@/domain/process-detail"; import type { DetailField, DetailMember, @@ -361,22 +363,7 @@ function Members({ const [expanded, setExpanded] = useState(true); const [pointerInside, setPointerInside] = useState(false); const [focusInside, setFocusInside] = useState(false); - // The PID order last shown; the baseline the next pinned tick replays. - const pinnedPids = useRef([]); - - useEffect(() => { - pinnedPids.current = []; - }, [resetKey]); - - const pinActive = pointerInside || focusInside; - const members = useMemo( - () => (pinActive ? pinMemberOrder(rankedMembers, pinnedPids.current) : rankedMembers), - [pinActive, rankedMembers], - ); - - useEffect(() => { - pinnedPids.current = members.map((member) => member.pid); - }, [members]); + const members = useOrderPin(rankedMembers, memberPid, pointerInside || focusInside, resetKey); return (
      @@ -423,46 +410,3 @@ function Members({ ); } -/** One member row: icon, name, and the active-metric value. */ -const MemberRow = memo( - function MemberRow({ - member, - onOpen, - }: { - member: DetailMember - onOpen: (pid: number, startedAtUnixMs?: number) => void - }) { - return ( - - ); - }, - (prev, next) => - prev.onOpen === next.onOpen && - prev.member.pid === next.member.pid && - prev.member.startedAtUnixMs === next.member.startedAtUnixMs && - prev.member.name === next.member.name && - prev.member.iconPngBase64 === next.member.iconPngBase64 && - prev.member.metricState === next.member.metricState && - prev.member.metricText === next.member.metricText && - prev.member.notResponding === next.member.notResponding, -); diff --git a/src/renderer/components/processes/process-explorer-view.tsx b/src/renderer/components/processes/process-explorer-view.tsx index 9c495ba..f1de018 100644 --- a/src/renderer/components/processes/process-explorer-view.tsx +++ b/src/renderer/components/processes/process-explorer-view.tsx @@ -222,7 +222,7 @@ function ProcessListPanel({ }, [active, searchInputRef]); const focusFirstRow = useCallback(() => { - listRef.current?.querySelector("button")?.focus(); + listRef.current?.querySelector("button[data-process-row]")?.focus(); }, []); const openTopMatch = useCallback(() => { @@ -247,6 +247,8 @@ function ProcessListPanel({ 0} onOpenSelection={onOpenSelection} diff --git a/src/renderer/components/processes/process-list.tsx b/src/renderer/components/processes/process-list.tsx index 8dcdd92..b1bcd6b 100644 --- a/src/renderer/components/processes/process-list.tsx +++ b/src/renderer/components/processes/process-list.tsx @@ -1,22 +1,31 @@ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type Ref } from "react"; +import { useCallback, useState, type KeyboardEvent, type Ref } from "react"; import { SnapshotStatus } from "@/gen/process_explorer"; import { ProcessRow } from "@/components/processes/process-row"; -import { pinGroupOrder, type DetailSelection, type ProcessGroup } from "@/domain/process-list"; +import { useOrderPin } from "@/components/processes/use-order-pin"; +import { + groupKey, + type DetailSelection, + type IconTable, + type ProcessGroup, + type SortMode, +} from "@/domain/process-list"; /** * The ranked, grouped process rows plus the loading/empty/unavailable states, * sharing one scroll area so the panel never resizes. * * While the pointer is inside the list - or a row has keyboard focus - row - * order is pinned via {@link pinGroupOrder}: a snapshot tick re-ranks rows, - * and a reorder landing between aiming and clicking (or between arrow - * presses) would open the wrong process. Values keep updating; only the order - * holds. Live ranking resumes when the pointer and focus leave (opening a - * detail unmounts the list, so a stale pin cannot outlive the interaction). + * order is pinned via {@link useOrderPin}: a snapshot tick re-ranks rows, and a + * reorder landing between aiming and clicking (or between arrow presses) would + * open the wrong process. Values keep updating; only the order holds. Live + * ranking resumes when the pointer and focus leave (opening a detail unmounts + * the list, so a stale pin cannot outlive the interaction). */ export function ProcessList({ groups: rankedGroups, + sort, + icons, status, hasQuery, onOpenSelection, @@ -24,6 +33,8 @@ export function ProcessList({ onExitTop, }: { groups: ProcessGroup[] + sort: SortMode + icons: IconTable status: SnapshotStatus hasQuery: boolean onOpenSelection: (selection: DetailSelection) => void @@ -32,30 +43,37 @@ export function ProcessList({ }) { const [pointerInside, setPointerInside] = useState(false); const [focusInside, setFocusInside] = useState(false); - // The key order last shown on screen; the baseline the next pinned tick replays. - const pinnedKeys = useRef([]); - + // Keys of groups expanded to show their member processes inline. Survives + // snapshot ticks; a key whose group has vanished is simply never read. + const [expandedKeys, setExpandedKeys] = useState>(() => new Set()); + const toggleExpanded = useCallback((key: string) => { + setExpandedKeys((current) => { + const next = new Set(current); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); const pinActive = pointerInside || focusInside; - const groups = useMemo( - () => (pinActive ? pinGroupOrder(rankedGroups, pinnedKeys.current) : rankedGroups), - [pinActive, rankedGroups], - ); - - // Track the order actually displayed: unpinned it follows the live ranking; - // pinned it evolves only by drop-outs and bottom appends, so a row that left - // the capped set and returned cannot reclaim a mid-list slot under the cursor. - useEffect(() => { - pinnedKeys.current = groups.map((group) => group.key); - }, [groups]); + const groups = useOrderPin(rankedGroups, groupKey, pinActive); - // Moves focus between row buttons on ArrowDown/ArrowUp; rows are the only - // buttons inside the container. Focusing scrolls the row into view natively. + // Moves focus between row buttons on ArrowDown/ArrowUp. Focus may sit on a + // row's content button or on its expand chevron; both live in the same
    • , + // so resolve the active element to its row before stepping. Focusing scrolls + // the row into view natively. function handleKeyDown(event: KeyboardEvent) { if (event.key !== "ArrowDown" && event.key !== "ArrowUp") { return; } - const rows = Array.from(event.currentTarget.querySelectorAll("button")); - const current = rows.indexOf(document.activeElement as HTMLButtonElement); + const rows = Array.from( + event.currentTarget.querySelectorAll("button[data-process-row]"), + ); + const active = document.activeElement as Element | null; + const activeRow = active?.closest("li")?.querySelector("button[data-process-row]"); + const current = activeRow ? rows.indexOf(activeRow) : -1; if (current < 0) { return; } @@ -74,7 +92,11 @@ export function ProcessList({ className="scrollbar-hidden flex-1 overflow-y-auto bg-background" onPointerOver={() => setPointerInside(true)} onPointerLeave={() => setPointerInside(false)} - onFocusCapture={() => setFocusInside(true)} + onFocusCapture={(event) => { + if (event.target instanceof Element && event.target.matches(":focus-visible")) { + setFocusInside(true); + } + }} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { setFocusInside(false); @@ -86,7 +108,15 @@ export function ProcessList({
        {groups.map((group) => (
      • - +
      • ))}
      diff --git a/src/renderer/components/processes/process-row.tsx b/src/renderer/components/processes/process-row.tsx index abf16c1..e4fbeac 100644 --- a/src/renderer/components/processes/process-row.tsx +++ b/src/renderer/components/processes/process-row.tsx @@ -1,8 +1,19 @@ -import { memo } from "react"; +import { memo, useCallback, useMemo } from "react"; +import { ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; +import { DisclosureContent } from "@/components/processes/disclosure"; +import { MemberRow } from "@/components/processes/member-row"; import { ProcessIcon } from "@/components/processes/process-icon"; -import { metricValueText, type DetailSelection, type ProcessGroup } from "@/domain/process-list"; +import { useOrderPin } from "@/components/processes/use-order-pin"; +import { memberPid, rankMembers, type DetailMember } from "@/domain/process-detail"; +import { + metricValueText, + type DetailSelection, + type IconTable, + type ProcessGroup, + type SortMode, +} from "@/domain/process-list"; /** * One fixed-height process row: app icon, name, an optional "+N" grouped-child @@ -10,75 +21,137 @@ import { metricValueText, type DetailSelection, type ProcessGroup } from "@/doma * opening the detail view. An app macOS marks Not Responding gets its name in * the destructive color plus a matching badge (Activity Monitor's convention), * since a hung app often shows nothing abnormal in CPU or memory. - * - * Memoized with a field-wise comparator: the projection rebuilds fresh group - * objects every tick, so comparing the rendered fields lets an unchanged row - * skip re-rendering. `onOpen` is a stable callback and is not compared. */ export const ProcessRow = memo(function ProcessRow({ group, + sort, + icons, + expanded, + pinned, onOpen, + onToggle, }: { group: ProcessGroup + sort: SortMode + icons: IconTable + expanded: boolean + pinned: boolean onOpen: (selection: DetailSelection) => void + onToggle: (key: string) => void }) { - return ( - + ) : null} + + + {group.notResponding ? ( + + Not Responding + + ) : null} + + {group.childCount > 0 ? ( + + +{group.childCount} + + ) : null} + + + + {metricValueText(group.metricState, group.metricText)} + + + + + {expandable ? ( + +
        + {children.map((child) => ( +
      • + +
      • + ))} +
      +
      + ) : null} + ); }, areGroupsEqual); function areGroupsEqual( - previous: { group: ProcessGroup; onOpen: (selection: DetailSelection) => void }, - next: { group: ProcessGroup; onOpen: (selection: DetailSelection) => void }, + previous: { group: ProcessGroup; expanded: boolean; pinned: boolean }, + next: { group: ProcessGroup; expanded: boolean; pinned: boolean }, ): boolean { const a = previous.group; const b = next.group; return ( + previous.expanded === next.expanded && + previous.pinned === next.pinned && a.key === b.key && a.name === b.name && a.iconPngBase64 === b.iconPngBase64 && @@ -88,10 +161,24 @@ function areGroupsEqual( a.metricState === b.metricState && a.metricText === b.metricText && a.notResponding === b.notResponding && + membersEqual(a.members, b.members) && areSelectionsEqual(a.openSelection, b.openSelection) ); } +/** Member identity/metric comparison so an expanded row re-renders on changes. */ +function membersEqual(a: ProcessGroup["members"], b: ProcessGroup["members"]): boolean { + if (a.length !== b.length) { + return false; + } + for (let index = 0; index < a.length; index += 1) { + if (a[index] !== b[index]) { + return false; + } + } + return true; +} + function areSelectionsEqual(left: DetailSelection, right: DetailSelection): boolean { if (left.kind === "group" && right.kind === "group") { return left.key === right.key; diff --git a/src/renderer/components/processes/use-order-pin.ts b/src/renderer/components/processes/use-order-pin.ts new file mode 100644 index 0000000..0d32d0a --- /dev/null +++ b/src/renderer/components/processes/use-order-pin.ts @@ -0,0 +1,42 @@ +import { useEffect, useMemo, useRef } from "react"; + +import { pinOrder } from "@/domain/process-list"; + +/** + * Holds a ranked list's row order steady while `active` (pointer or keyboard + * focus inside it), so a snapshot re-rank can't move a row between aiming and + * clicking. Values keep updating; only the order is held. Used by every pinned + * list: the group list, the detail Members section, and the inline expanded + * children. + * + * `resetKey` drops the held order when it changes (e.g. the drilled target or + * sort switched), so the next pinned tick re-baselines from the live ranking. + */ +export function useOrderPin( + ranked: Item[], + getKey: (item: Item) => Key, + active: boolean, + resetKey?: unknown, +): Item[] { + // The identity order last shown; the baseline a pinned tick replays. + const pinnedKeys = useRef([]); + // Held in a ref so the reorder never re-runs merely because the reader's + // identity changed - only `ranked`/`active` should drive it. + const getKeyRef = useRef(getKey); + getKeyRef.current = getKey; + + useEffect(() => { + pinnedKeys.current = []; + }, [resetKey]); + + const ordered = useMemo( + () => (active ? pinOrder(ranked, getKeyRef.current, pinnedKeys.current) : ranked), + [active, ranked], + ); + + useEffect(() => { + pinnedKeys.current = ordered.map(getKeyRef.current); + }, [ordered]); + + return ordered; +} diff --git a/src/renderer/domain/process-detail.ts b/src/renderer/domain/process-detail.ts index 1c195a5..9ccc47f 100644 --- a/src/renderer/domain/process-detail.ts +++ b/src/renderer/domain/process-detail.ts @@ -55,6 +55,11 @@ export interface DetailMember { notResponding: boolean; } +/** Stable identity reader for a member, for order-pinning member lists. */ +export function memberPid(member: DetailMember): number { + return member.pid; +} + /** * Presentation model for the detail view of one selected group (or one * process - then a single-member group). @@ -186,7 +191,7 @@ function detailUser(row: ProcessRow): DetailField { return { state: missingState(user?.status) }; } -function buildMember(row: ProcessRow, sort: SortMode, icons: IconTable): DetailMember { +export function buildMember(row: ProcessRow, sort: SortMode, icons: IconTable): DetailMember { const cell = rowMetric(row, sort); const metricState = cellState(cell); return { @@ -206,6 +211,23 @@ function buildMember(row: ProcessRow, sort: SortMode, icons: IconTable): DetailM * collapsed list shows); CPU/memory/threads/CPU-time are summed across all * members so a grouped app reports its whole footprint. */ +/** + * A group's members as display rows ranked by the active metric (descending), + * with a PID tie-break so equal-value rows (e.g. idle 0.00% members) stay + * stable across ticks. Shared by the detail view and the inline expanded list + * so both order members identically. + */ +export function rankMembers(group: ProcessGroup, sort: SortMode, icons: IconTable): DetailMember[] { + const read = sort === "cpu" ? rowCpu : rowMemory; + return group.members + .slice() + .sort((left, right) => { + const delta = (read(right).value ?? 0) - (read(left).value ?? 0); + return delta !== 0 ? delta : rowPid(left) - rowPid(right); + }) + .map((row) => buildMember(row, sort, icons)); +} + export function buildProcessDetail(group: ProcessGroup, sort: SortMode, icons: IconTable): ProcessDetail { const representative = group.members[0]; const statics = representative.statics; @@ -215,20 +237,9 @@ export function buildProcessDetail(group: ProcessGroup, sort: SortMode, icons: I const parentAvailable = statics?.parentStatus === FieldStatus.FIELD_STATUS_OK && statics.parentPid > 0; - // Members ranked by the active metric like the main list, with a PID - // tie-break so equal-value rows (e.g. idle 0.00% members) stay stable across - // ticks. The representative stays group.members[0] for the header identity; - // only the displayed list is ranked. - const members = - group.memberCount > 1 - ? group.members - .slice() - .sort((left, right) => { - const delta = (read(right).value ?? 0) - (read(left).value ?? 0); - return delta !== 0 ? delta : rowPid(left) - rowPid(right); - }) - .map((row) => buildMember(row, sort, icons)) - : []; + // The representative stays group.members[0] for the header identity; only the + // displayed list is ranked. + const members = group.memberCount > 1 ? rankMembers(group, sort, icons) : []; return { key: group.key, @@ -255,26 +266,3 @@ export function buildProcessDetail(group: ProcessGroup, sort: SortMode, icons: I notResponding: group.notResponding, }; } - -/** - * Reorders members to match a previously rendered PID order, so the Members - * list can pin row positions while the pointer or focus is inside it (a live - * re-rank would move rows between aiming and clicking). Mirrors - * {@link pinGroupOrder}: only the order is held; member objects and their - * values are the fresh ones, new PIDs append, vanished PIDs drop out. - */ -export function pinMemberOrder(members: DetailMember[], pinnedPids: number[]): DetailMember[] { - if (pinnedPids.length === 0) { - return members; - } - - const rankByPid = new Map(pinnedPids.map((pid, index) => [pid, index] as const)); - const pinned: DetailMember[] = []; - const fresh: DetailMember[] = []; - for (const member of members) { - (rankByPid.has(member.pid) ? pinned : fresh).push(member); - } - pinned.sort((left, right) => (rankByPid.get(left.pid) ?? 0) - (rankByPid.get(right.pid) ?? 0)); - - return [...pinned, ...fresh]; -} diff --git a/src/renderer/domain/process-list.ts b/src/renderer/domain/process-list.ts index c44c71e..c040596 100644 --- a/src/renderer/domain/process-list.ts +++ b/src/renderer/domain/process-list.ts @@ -494,29 +494,39 @@ export function projectProcessList( } /** - * Reorders projected groups to match a previously rendered key order, so the - * list can pin row positions while the pointer is inside it (a live re-rank - * would move rows between aiming and clicking). Only the order is held; the - * group objects and their metric values are the fresh ones. New arrivals - * append after the pinned rows so they never displace a row mid-list; vanished - * keys drop out naturally. + * Reorders freshly ranked items to match a previously rendered identity order, + * so a list can pin row positions while the pointer or focus is inside it (a + * live re-rank would move rows between aiming and clicking). Only the order is + * held; the items and their values are the fresh ones. New arrivals append + * after the pinned rows so they never displace a row mid-list; vanished + * identities drop out naturally. Shared by every pinned list (groups, detail + * members, inline expanded children). */ -export function pinGroupOrder(groups: ProcessGroup[], pinnedKeys: string[]): ProcessGroup[] { +export function pinOrder( + items: Item[], + getKey: (item: Item) => Key, + pinnedKeys: Key[], +): Item[] { if (pinnedKeys.length === 0) { - return groups; + return items; } const rankByKey = new Map(pinnedKeys.map((key, index) => [key, index] as const)); - const pinned: ProcessGroup[] = []; - const fresh: ProcessGroup[] = []; - for (const group of groups) { - (rankByKey.has(group.key) ? pinned : fresh).push(group); + const pinned: Item[] = []; + const fresh: Item[] = []; + for (const item of items) { + (rankByKey.has(getKey(item)) ? pinned : fresh).push(item); } - pinned.sort((left, right) => (rankByKey.get(left.key) ?? 0) - (rankByKey.get(right.key) ?? 0)); + pinned.sort((left, right) => (rankByKey.get(getKey(left)) ?? 0) - (rankByKey.get(getKey(right)) ?? 0)); return [...pinned, ...fresh]; } +/** Stable identity reader for a group, for order-pinning the list. */ +export function groupKey(group: ProcessGroup): string { + return group.key; +} + /** * Finds one group by key for the detail view, folding only the matching rows * through the same {@link buildGroupRow} path the list uses, so the diff --git a/tests/unit/process-list.test.ts b/tests/unit/process-list.test.ts index 767cec5..de1d70d 100644 --- a/tests/unit/process-list.test.ts +++ b/tests/unit/process-list.test.ts @@ -6,7 +6,6 @@ import { findGroupByKey, isPending, okString, - pinGroupOrder, projectProcessList, resolveSelection, rowCpu, @@ -187,47 +186,6 @@ describe("projectProcessList - sorting", () => { }); }); -describe("pinGroupOrder", () => { - function tick(cpuByName: Record) { - const rows = Object.entries(cpuByName).map(([name, cpu], index) => - makeRow({ pid: index + 1, commandName: name, startedAtUnixMs: index + 1, cpuPercent: cpu }), - ); - return projectProcessList(makeSnapshot(rows), "cpu", ""); - } - - it("replays the pinned order over a re-ranked projection, keeping fresh values", () => { - const before = tick({ alpha: 50, beta: 40, gamma: 30 }); - const pinned = before.map((group) => group.key); - - // Next tick: gamma spikes to the top; the pinned order must not move. - const next = tick({ alpha: 10, beta: 20, gamma: 90 }); - const replayed = pinGroupOrder(next, pinned); - - expect(replayed.map((group) => group.name)).toEqual(["alpha", "beta", "gamma"]); - // The group objects are the fresh ones - values keep ticking while pinned. - expect(replayed.map((group) => group.sortValue)).toEqual([10, 20, 90]); - }); - - it("appends new groups after the pinned rows and drops vanished ones", () => { - const pinned = tick({ alpha: 50, beta: 40, gamma: 30 }).map((group) => group.key); - - // beta exited; delta arrived at the top of the ranking. - const nextRows = [ - makeRow({ pid: 1, commandName: "alpha", startedAtUnixMs: 1, cpuPercent: 10 }), - makeRow({ pid: 3, commandName: "gamma", startedAtUnixMs: 3, cpuPercent: 30 }), - makeRow({ pid: 4, commandName: "delta", startedAtUnixMs: 4, cpuPercent: 95 }), - ]; - const next = projectProcessList(makeSnapshot(nextRows), "cpu", ""); - - expect(pinGroupOrder(next, pinned).map((group) => group.name)).toEqual(["alpha", "gamma", "delta"]); - }); - - it("passes groups through unchanged when nothing is pinned", () => { - const groups = tick({ alpha: 50, beta: 40 }); - expect(pinGroupOrder(groups, [])).toBe(groups); - }); -}); - describe("projectProcessList - search", () => { const rows = [ makeRow({ From 4e0b65cf46f197e93a9af82bf45e97c58cbd8616 Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Tue, 16 Jun 2026 19:45:44 +0300 Subject: [PATCH 03/16] Polish documentation --- .../components/processes/process-detail.tsx | 4 ++-- src/renderer/components/processes/process-row.tsx | 10 ++++++---- src/renderer/domain/process-detail.ts | 13 +++++++------ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/renderer/components/processes/process-detail.tsx b/src/renderer/components/processes/process-detail.tsx index 73ceaf1..5ace3eb 100644 --- a/src/renderer/components/processes/process-detail.tsx +++ b/src/renderer/components/processes/process-detail.tsx @@ -343,8 +343,8 @@ function ScrollableValue({ /** * The expandable Members section for a multi-process app. The disclosure * header carries the group's selected-metric total on the right; toggling it - * reveals the member processes (representative first), each drillable into - * its own detail. Starts expanded; scrolls within a bounded box. + * reveals the member processes (ranked by the active metric), each drillable + * into its own detail. Starts expanded; scrolls within a bounded box. */ function Members({ members: rankedMembers, diff --git a/src/renderer/components/processes/process-row.tsx b/src/renderer/components/processes/process-row.tsx index e4fbeac..7134e0f 100644 --- a/src/renderer/components/processes/process-row.tsx +++ b/src/renderer/components/processes/process-row.tsx @@ -17,10 +17,12 @@ import { /** * One fixed-height process row: app icon, name, an optional "+N" grouped-child - * badge, and the right-aligned active metric. The whole row is a button - * opening the detail view. An app macOS marks Not Responding gets its name in - * the destructive color plus a matching badge (Activity Monitor's convention), - * since a hung app often shows nothing abnormal in CPU or memory. + * badge, and the right-aligned active metric. The row body is a button opening + * the detail view; a grouped row also carries a leading chevron that expands + * its member processes inline (ranked, and held in place while the list is + * pinned). An app macOS marks Not Responding gets its name in the destructive + * color plus a matching badge (Activity Monitor's convention), since a hung app + * often shows nothing abnormal in CPU or memory. */ export const ProcessRow = memo(function ProcessRow({ group, diff --git a/src/renderer/domain/process-detail.ts b/src/renderer/domain/process-detail.ts index 9ccc47f..e5ec653 100644 --- a/src/renderer/domain/process-detail.ts +++ b/src/renderer/domain/process-detail.ts @@ -191,6 +191,7 @@ function detailUser(row: ProcessRow): DetailField { return { state: missingState(user?.status) }; } +/** Projects one process row into a member display item under the active sort. */ export function buildMember(row: ProcessRow, sort: SortMode, icons: IconTable): DetailMember { const cell = rowMetric(row, sort); const metricState = cellState(cell); @@ -205,12 +206,6 @@ export function buildMember(row: ProcessRow, sort: SortMode, icons: IconTable): }; } -/** - * Projects a selected {@link ProcessGroup} into its display model. Identity, - * path, argv, and started-at come from the representative (the row the - * collapsed list shows); CPU/memory/threads/CPU-time are summed across all - * members so a grouped app reports its whole footprint. - */ /** * A group's members as display rows ranked by the active metric (descending), * with a PID tie-break so equal-value rows (e.g. idle 0.00% members) stay @@ -228,6 +223,12 @@ export function rankMembers(group: ProcessGroup, sort: SortMode, icons: IconTabl .map((row) => buildMember(row, sort, icons)); } +/** + * Projects a selected {@link ProcessGroup} into its display model. Identity, + * path, argv, and started-at come from the representative (the row the + * collapsed list shows); CPU/memory/threads/CPU-time are summed across all + * members so a grouped app reports its whole footprint. + */ export function buildProcessDetail(group: ProcessGroup, sort: SortMode, icons: IconTable): ProcessDetail { const representative = group.members[0]; const statics = representative.statics; From 41252c24a8e5c2d254ddc131ff3ee5bc251bc888 Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Tue, 16 Jun 2026 20:06:29 +0300 Subject: [PATCH 04/16] FIx process row comparison logic --- .../components/processes/process-row.tsx | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/renderer/components/processes/process-row.tsx b/src/renderer/components/processes/process-row.tsx index 7134e0f..5811df5 100644 --- a/src/renderer/components/processes/process-row.tsx +++ b/src/renderer/components/processes/process-row.tsx @@ -151,21 +151,25 @@ function areGroupsEqual( ): boolean { const a = previous.group; const b = next.group; - return ( - previous.expanded === next.expanded && - previous.pinned === next.pinned && - a.key === b.key && - a.name === b.name && - a.iconPngBase64 === b.iconPngBase64 && - a.system === b.system && - a.childCount === b.childCount && - a.memberCount === b.memberCount && - a.metricState === b.metricState && - a.metricText === b.metricText && - a.notResponding === b.notResponding && - membersEqual(a.members, b.members) && - areSelectionsEqual(a.openSelection, b.openSelection) - ); + if ( + previous.expanded !== next.expanded || + a.key !== b.key || + a.name !== b.name || + a.iconPngBase64 !== b.iconPngBase64 || + a.system !== b.system || + a.childCount !== b.childCount || + a.memberCount !== b.memberCount || + a.metricState !== b.metricState || + a.metricText !== b.metricText || + a.notResponding !== b.notResponding || + !areSelectionsEqual(a.openSelection, b.openSelection) + ) { + return false; + } + if (!next.expanded) { + return true; + } + return previous.pinned === next.pinned && membersEqual(a.members, b.members); } /** Member identity/metric comparison so an expanded row re-renders on changes. */ From 9119636762070b24ecfa80b1df5fff0f22a7b784 Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Tue, 16 Jun 2026 20:10:32 +0300 Subject: [PATCH 05/16] Add tests for pinOrder function to validate pinned item behavior --- tests/unit/process-list.test.ts | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/unit/process-list.test.ts b/tests/unit/process-list.test.ts index de1d70d..d4b91ec 100644 --- a/tests/unit/process-list.test.ts +++ b/tests/unit/process-list.test.ts @@ -6,6 +6,7 @@ import { findGroupByKey, isPending, okString, + pinOrder, projectProcessList, resolveSelection, rowCpu, @@ -186,6 +187,60 @@ describe("projectProcessList - sorting", () => { }); }); +describe("pinOrder", () => { + // Simple keyed items stand in for the ranked rows/groups the list pins: each + // tick rebuilds them with fresh `value`s, while the pinned key order holds. + interface Item { + key: string; + value: number; + } + const item = (key: string, value: number): Item => ({ key, value }); + const keyOf = (it: Item) => it.key; + + it("replays the pinned identity order over a re-ranked list, keeping fresh values", () => { + const pinned = ["a", "b", "c"]; + // Fresh tick ranked c-first, but the pinned order must win. + const ranked = [item("c", 90), item("a", 10), item("b", 20)]; + + const result = pinOrder(ranked, keyOf, pinned); + + expect(result.map(keyOf)).toEqual(["a", "b", "c"]); + // The items are the fresh ones - only the order is held, not the values. + expect(result.map((it) => it.value)).toEqual([10, 20, 90]); + }); + + it("appends new keys after the pinned ones, in their ranked order", () => { + const pinned = ["a", "b"]; + // d and c are new arrivals; d outranks c but both go below the pinned rows. + const ranked = [item("d", 95), item("b", 20), item("c", 50), item("a", 10)]; + + const result = pinOrder(ranked, keyOf, pinned); + + expect(result.map(keyOf)).toEqual(["a", "b", "d", "c"]); + }); + + it("drops vanished pinned keys without leaving a gap", () => { + const pinned = ["a", "b", "c"]; + // b vanished this tick; a and c keep their relative pinned order. + const ranked = [item("c", 30), item("a", 10)]; + + expect(pinOrder(ranked, keyOf, pinned).map(keyOf)).toEqual(["a", "c"]); + }); + + it("returns the input unchanged when nothing is pinned", () => { + const ranked = [item("a", 50), item("b", 40)]; + expect(pinOrder(ranked, keyOf, [])).toBe(ranked); + }); + + it("appends and drops together: vanished key gone, new key after the pinned ones", () => { + const pinned = ["a", "b", "c"]; + // b exited; d arrived at the top of the ranking. + const ranked = [item("d", 95), item("a", 10), item("c", 30)]; + + expect(pinOrder(ranked, keyOf, pinned).map(keyOf)).toEqual(["a", "c", "d"]); + }); +}); + describe("projectProcessList - search", () => { const rows = [ makeRow({ From 8b728eb165924b9ea269dec5fd2db78db5096434 Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Tue, 16 Jun 2026 20:12:09 +0300 Subject: [PATCH 06/16] Update resetKey in process-detail to include startedAtUnixMs for better uniqueness --- src/renderer/components/processes/process-detail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/processes/process-detail.tsx b/src/renderer/components/processes/process-detail.tsx index 5ace3eb..6a6fca0 100644 --- a/src/renderer/components/processes/process-detail.tsx +++ b/src/renderer/components/processes/process-detail.tsx @@ -153,7 +153,7 @@ export function ProcessDetailView({ members={detail.members} memberCount={detail.memberCount} total={detail.total} - resetKey={`${detail.pid}:${detail.totalSort}`} + resetKey={`${detail.pid}:${detail.startedAtUnixMs}:${detail.totalSort}`} onOpenMember={onOpenMember} /> ) : ( From ae9788b9a44f90783737576ad13abb8e519a8012 Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Tue, 16 Jun 2026 20:33:12 +0300 Subject: [PATCH 07/16] Fix resetKey handling and ensure correct item ordering --- .../components/processes/use-order-pin.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/renderer/components/processes/use-order-pin.ts b/src/renderer/components/processes/use-order-pin.ts index 0d32d0a..bc6d7b0 100644 --- a/src/renderer/components/processes/use-order-pin.ts +++ b/src/renderer/components/processes/use-order-pin.ts @@ -18,21 +18,20 @@ export function useOrderPin( active: boolean, resetKey?: unknown, ): Item[] { - // The identity order last shown; the baseline a pinned tick replays. const pinnedKeys = useRef([]); - // Held in a ref so the reorder never re-runs merely because the reader's - // identity changed - only `ranked`/`active` should drive it. const getKeyRef = useRef(getKey); getKeyRef.current = getKey; - useEffect(() => { + const lastResetKey = useRef(resetKey); + if (resetKey !== lastResetKey.current) { + lastResetKey.current = resetKey; pinnedKeys.current = []; - }, [resetKey]); + } - const ordered = useMemo( - () => (active ? pinOrder(ranked, getKeyRef.current, pinnedKeys.current) : ranked), - [active, ranked], - ); + const ordered = useMemo(() => { + void resetKey; + return active ? pinOrder(ranked, getKeyRef.current, pinnedKeys.current) : ranked; + }, [active, ranked, resetKey]); useEffect(() => { pinnedKeys.current = ordered.map(getKeyRef.current); From 083045a798137911ebda484228aecf66df8b28c0 Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Tue, 16 Jun 2026 20:39:26 +0300 Subject: [PATCH 08/16] Remove unnecessary blank line --- src/renderer/components/processes/process-detail.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/renderer/components/processes/process-detail.tsx b/src/renderer/components/processes/process-detail.tsx index 6a6fca0..692dc53 100644 --- a/src/renderer/components/processes/process-detail.tsx +++ b/src/renderer/components/processes/process-detail.tsx @@ -409,4 +409,3 @@ function Members({
    • ); } - From 05b8cedd87756850c5bc53a19f7d484e08edd73f Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Tue, 16 Jun 2026 20:56:07 +0300 Subject: [PATCH 09/16] Fix DisclosureContent styles --- src/renderer/components/processes/disclosure.tsx | 13 +++---------- .../components/processes/process-detail.tsx | 2 +- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/processes/disclosure.tsx b/src/renderer/components/processes/disclosure.tsx index 52eba38..98f5826 100644 --- a/src/renderer/components/processes/disclosure.tsx +++ b/src/renderer/components/processes/disclosure.tsx @@ -5,14 +5,7 @@ import { appGateway } from "@/gateway/app-gateway"; import { cn } from "@/lib/utils"; /** - * Reusable disclosure and copy primitives for the detail view. Sensitive - * process text (paths, argv) is copied only on explicit user action and - * routes through main because the renderer is sandboxed. - */ - -/** - * Smooth height/opacity wrapper for disclosure bodies. Content stays mounted - * so close animations can run; `inert` keeps hidden controls unreachable. + * Smooth height/opacity wrapper for disclosure bodies. */ export function DisclosureContent({ open, @@ -26,8 +19,8 @@ export function DisclosureContent({ aria-hidden={!open} inert={open ? undefined : true} className={cn( - "grid transition-[grid-template-rows,opacity,margin-top] duration-150 ease-out motion-reduce:transition-none", - open ? "mt-1.5 grid-rows-[1fr] opacity-100" : "mt-0 grid-rows-[0fr] opacity-0", + "grid transition-[grid-template-rows,opacity] duration-150 ease-out motion-reduce:transition-none", + open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0", )} >
      {children}
      diff --git a/src/renderer/components/processes/process-detail.tsx b/src/renderer/components/processes/process-detail.tsx index 692dc53..23dfd6a 100644 --- a/src/renderer/components/processes/process-detail.tsx +++ b/src/renderer/components/processes/process-detail.tsx @@ -389,7 +389,7 @@ function Members({
        setPointerInside(true)} onPointerLeave={() => setPointerInside(false)} onFocusCapture={() => setFocusInside(true)} From edfa06068d6fca600f2002af2531518278c952ca Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Tue, 16 Jun 2026 21:09:46 +0300 Subject: [PATCH 10/16] Address edge cases in pinned state --- src/renderer/components/processes/process-detail.tsx | 6 +++--- src/renderer/components/processes/process-list.tsx | 2 +- src/renderer/components/processes/process-row.tsx | 11 ++++++----- src/renderer/components/processes/use-order-pin.ts | 10 ++++------ src/renderer/domain/process-detail.ts | 4 ++-- 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/renderer/components/processes/process-detail.tsx b/src/renderer/components/processes/process-detail.tsx index 23dfd6a..663565e 100644 --- a/src/renderer/components/processes/process-detail.tsx +++ b/src/renderer/components/processes/process-detail.tsx @@ -12,7 +12,7 @@ import { ProcessIcon } from "@/components/processes/process-icon"; import { ProcessSortControl } from "@/components/processes/process-sort-control"; import { useOrderPin } from "@/components/processes/use-order-pin"; import { metricValueText, type SortMode } from "@/domain/process-list"; -import { memberPid } from "@/domain/process-detail"; +import { memberKey } from "@/domain/process-detail"; import type { DetailField, DetailMember, @@ -363,7 +363,7 @@ function Members({ const [expanded, setExpanded] = useState(true); const [pointerInside, setPointerInside] = useState(false); const [focusInside, setFocusInside] = useState(false); - const members = useOrderPin(rankedMembers, memberPid, pointerInside || focusInside, resetKey); + const members = useOrderPin(rankedMembers, memberKey, pointerInside || focusInside, resetKey); return (
        @@ -400,7 +400,7 @@ function Members({ }} > {members.map((member) => ( -
      • +
      • ))} diff --git a/src/renderer/components/processes/process-list.tsx b/src/renderer/components/processes/process-list.tsx index b1bcd6b..54f2a1a 100644 --- a/src/renderer/components/processes/process-list.tsx +++ b/src/renderer/components/processes/process-list.tsx @@ -58,7 +58,7 @@ export function ProcessList({ }); }, []); const pinActive = pointerInside || focusInside; - const groups = useOrderPin(rankedGroups, groupKey, pinActive); + const groups = useOrderPin(rankedGroups, groupKey, pinActive, sort); // Moves focus between row buttons on ArrowDown/ArrowUp. Focus may sit on a // row's content button or on its expand chevron; both live in the same
      • , diff --git a/src/renderer/components/processes/process-row.tsx b/src/renderer/components/processes/process-row.tsx index 5811df5..9fb7be5 100644 --- a/src/renderer/components/processes/process-row.tsx +++ b/src/renderer/components/processes/process-row.tsx @@ -6,7 +6,7 @@ import { DisclosureContent } from "@/components/processes/disclosure"; import { MemberRow } from "@/components/processes/member-row"; import { ProcessIcon } from "@/components/processes/process-icon"; import { useOrderPin } from "@/components/processes/use-order-pin"; -import { memberPid, rankMembers, type DetailMember } from "@/domain/process-detail"; +import { memberKey, rankMembers, type DetailMember } from "@/domain/process-detail"; import { metricValueText, type DetailSelection, @@ -50,7 +50,7 @@ export const ProcessRow = memo(function ProcessRow({ () => (expanded ? rankMembers(group, sort, icons) : []), [expanded, group, sort, icons], ); - const children = useOrderPin(ranked, memberPid, pinned); + const children = useOrderPin(ranked, memberKey, pinned, sort); // Adapt MemberRow's (pid, startedAt) open to this row's selection open, kept // stable so MemberRow's memo holds across ticks. @@ -134,7 +134,7 @@ export const ProcessRow = memo(function ProcessRow({
          {children.map((child) => ( -
        • +
        • ))} @@ -146,12 +146,13 @@ export const ProcessRow = memo(function ProcessRow({ }, areGroupsEqual); function areGroupsEqual( - previous: { group: ProcessGroup; expanded: boolean; pinned: boolean }, - next: { group: ProcessGroup; expanded: boolean; pinned: boolean }, + previous: { group: ProcessGroup; sort: SortMode; expanded: boolean; pinned: boolean }, + next: { group: ProcessGroup; sort: SortMode; expanded: boolean; pinned: boolean }, ): boolean { const a = previous.group; const b = next.group; if ( + previous.sort !== next.sort || previous.expanded !== next.expanded || a.key !== b.key || a.name !== b.name || diff --git a/src/renderer/components/processes/use-order-pin.ts b/src/renderer/components/processes/use-order-pin.ts index bc6d7b0..7aefc5e 100644 --- a/src/renderer/components/processes/use-order-pin.ts +++ b/src/renderer/components/processes/use-order-pin.ts @@ -19,8 +19,6 @@ export function useOrderPin( resetKey?: unknown, ): Item[] { const pinnedKeys = useRef([]); - const getKeyRef = useRef(getKey); - getKeyRef.current = getKey; const lastResetKey = useRef(resetKey); if (resetKey !== lastResetKey.current) { @@ -30,12 +28,12 @@ export function useOrderPin( const ordered = useMemo(() => { void resetKey; - return active ? pinOrder(ranked, getKeyRef.current, pinnedKeys.current) : ranked; - }, [active, ranked, resetKey]); + return active ? pinOrder(ranked, getKey, pinnedKeys.current) : ranked; + }, [active, getKey, ranked, resetKey]); useEffect(() => { - pinnedKeys.current = ordered.map(getKeyRef.current); - }, [ordered]); + pinnedKeys.current = ordered.map(getKey); + }, [getKey, ordered]); return ordered; } diff --git a/src/renderer/domain/process-detail.ts b/src/renderer/domain/process-detail.ts index e5ec653..d6b7647 100644 --- a/src/renderer/domain/process-detail.ts +++ b/src/renderer/domain/process-detail.ts @@ -56,8 +56,8 @@ export interface DetailMember { } /** Stable identity reader for a member, for order-pinning member lists. */ -export function memberPid(member: DetailMember): number { - return member.pid; +export function memberKey(member: DetailMember): string { + return `${member.pid}:${member.startedAtUnixMs ?? "unknown"}`; } /** From b5ab4fb43ce4a4a6146e2b429e8801f065a5df33 Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Tue, 16 Jun 2026 21:38:40 +0300 Subject: [PATCH 11/16] Refactor resetKey handling in useOrderPin --- src/renderer/components/processes/process-row.tsx | 1 - src/renderer/components/processes/use-order-pin.ts | 14 +++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/processes/process-row.tsx b/src/renderer/components/processes/process-row.tsx index 9fb7be5..8c46763 100644 --- a/src/renderer/components/processes/process-row.tsx +++ b/src/renderer/components/processes/process-row.tsx @@ -173,7 +173,6 @@ function areGroupsEqual( return previous.pinned === next.pinned && membersEqual(a.members, b.members); } -/** Member identity/metric comparison so an expanded row re-renders on changes. */ function membersEqual(a: ProcessGroup["members"], b: ProcessGroup["members"]): boolean { if (a.length !== b.length) { return false; diff --git a/src/renderer/components/processes/use-order-pin.ts b/src/renderer/components/processes/use-order-pin.ts index 7aefc5e..bfa78c5 100644 --- a/src/renderer/components/processes/use-order-pin.ts +++ b/src/renderer/components/processes/use-order-pin.ts @@ -19,21 +19,17 @@ export function useOrderPin( resetKey?: unknown, ): Item[] { const pinnedKeys = useRef([]); - const lastResetKey = useRef(resetKey); - if (resetKey !== lastResetKey.current) { - lastResetKey.current = resetKey; - pinnedKeys.current = []; - } + const baselineChanged = resetKey !== lastResetKey.current; const ordered = useMemo(() => { - void resetKey; - return active ? pinOrder(ranked, getKey, pinnedKeys.current) : ranked; - }, [active, getKey, ranked, resetKey]); + return active && !baselineChanged ? pinOrder(ranked, getKey, pinnedKeys.current) : ranked; + }, [active, baselineChanged, getKey, ranked]); useEffect(() => { + lastResetKey.current = resetKey; pinnedKeys.current = ordered.map(getKey); - }, [getKey, ordered]); + }, [getKey, ordered, resetKey]); return ordered; } From 1c04d47716acdd22da72fc11dca76b8c76703d5c Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Tue, 16 Jun 2026 22:04:43 +0300 Subject: [PATCH 12/16] Align the member icons under the group icon --- src/renderer/components/processes/member-row.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/processes/member-row.tsx b/src/renderer/components/processes/member-row.tsx index 1b2aa4e..cbe198c 100644 --- a/src/renderer/components/processes/member-row.tsx +++ b/src/renderer/components/processes/member-row.tsx @@ -30,7 +30,7 @@ export const MemberRow = memo( title={`${member.name} - PID ${member.pid}`} className={cn( "flex h-9 w-full items-center gap-2.5 rounded-md pr-1 text-left transition-colors hover:bg-muted/50 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring", - indented ? "pl-8" : "pl-2", + indented ? "pl-6" : "pl-2", )} > From c26f89bb928378019af658fe706be9dc776a50ff Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Wed, 17 Jun 2026 13:06:55 +0300 Subject: [PATCH 13/16] Retain the last members through the close animation --- src/renderer/components/processes/process-row.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/processes/process-row.tsx b/src/renderer/components/processes/process-row.tsx index 8c46763..50400d2 100644 --- a/src/renderer/components/processes/process-row.tsx +++ b/src/renderer/components/processes/process-row.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback, useMemo, useRef } from "react"; import { ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -50,7 +50,12 @@ export const ProcessRow = memo(function ProcessRow({ () => (expanded ? rankMembers(group, sort, icons) : []), [expanded, group, sort, icons], ); - const children = useOrderPin(ranked, memberKey, pinned, sort); + const ordered = useOrderPin(ranked, memberKey, pinned, sort); + const lastChildren = useRef([]); + if (expanded) { + lastChildren.current = ordered; + } + const children = expanded ? ordered : lastChildren.current; // Adapt MemberRow's (pid, startedAt) open to this row's selection open, kept // stable so MemberRow's memo holds across ticks. From af493213fccf31df4cf252e2a8832b4e549685a2 Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Wed, 17 Jun 2026 15:20:47 +0300 Subject: [PATCH 14/16] Add MemoryGraph component and integrate CPU/Memory history tracking --- src/renderer/components/metrics/cpu-graph.tsx | 6 +- .../components/metrics/memory-graph.tsx | 54 ++++++++++++++++ .../components/processes/process-actions.tsx | 11 ++-- .../components/processes/process-detail.tsx | 63 ++++++++++++++----- .../processes/process-explorer-view.tsx | 3 + .../processes/use-process-histories.ts | 63 +++++++++++++++++++ src/renderer/domain/process-detail.ts | 17 +++++ src/renderer/domain/process-list.ts | 36 +++++++++++ src/renderer/domain/sample-history.ts | 3 + tests/unit/process-detail.test.ts | 4 ++ tests/unit/process-list.test.ts | 33 ++++++++++ 11 files changed, 269 insertions(+), 24 deletions(-) create mode 100644 src/renderer/components/metrics/memory-graph.tsx create mode 100644 src/renderer/components/processes/use-process-histories.ts diff --git a/src/renderer/components/metrics/cpu-graph.tsx b/src/renderer/components/metrics/cpu-graph.tsx index 2713ddb..5b64f80 100644 --- a/src/renderer/components/metrics/cpu-graph.tsx +++ b/src/renderer/components/metrics/cpu-graph.tsx @@ -3,11 +3,11 @@ import { useRef, type PointerEvent as ReactPointerEvent } from "react"; import { cn } from "@/lib/utils"; import type { MetricState } from "@/domain/metric-view"; import { areaRuns } from "@/domain/area-path"; -import { HISTORY_CAPACITY, sampleIndexAtFraction } from "@/domain/sample-history"; +import { HISTORY_CAPACITY, sampleIndexAtFraction, type HistorySample } from "@/domain/sample-history"; import { AreaLayer, Baseline, ScrubBand } from "@/components/metrics/area-layer"; -/** A 0-100 percent reading, or `null` for a tick whose reading was not OK. */ -export type CpuSample = number | null; +/** A CPU percent reading, or `null` for a tick whose reading was not OK. */ +export type CpuSample = HistorySample; const FILL_BY_STATE: Record = { ok: "text-success", diff --git a/src/renderer/components/metrics/memory-graph.tsx b/src/renderer/components/metrics/memory-graph.tsx new file mode 100644 index 0000000..93dbb0d --- /dev/null +++ b/src/renderer/components/metrics/memory-graph.tsx @@ -0,0 +1,54 @@ +import { useRef, type PointerEvent as ReactPointerEvent } from "react"; + +import { areaRuns } from "@/domain/area-path"; +import { HISTORY_CAPACITY, sampleIndexAtFraction, type HistorySample } from "@/domain/sample-history"; +import { AreaLayer, Baseline, ScrubBand } from "@/components/metrics/area-layer"; + +const PEAK = 88; +const BASELINE_Y = 99.5; +// Keep page-sized memory wobble from turning into a dramatic spike. +const MIN_SPAN_BYTES = 16 * 1024 * 1024; + +/** Floating-axis memory trend for one process or group. */ +export function MemoryGraph({ + history, + scrubIndex, + onScrub, +}: { + history: HistorySample[]; + scrubIndex: number | null; + onScrub: (index: number | null) => void; +}) { + const ref = useRef(null); + const offset = HISTORY_CAPACITY - history.length; + + const values = history.filter((sample): sample is number => sample !== null); + const max = values.length > 0 ? Math.max(...values) : 0; + const min = values.length > 0 ? Math.min(...values) : 0; + const base = Math.max(0, min - MIN_SPAN_BYTES * 0.25); + const span = Math.max(MIN_SPAN_BYTES, max - base); + const runs = areaRuns(history, offset, (sample) => ((sample - base) / span) * PEAK, 100, -1); + + const handleMove = (event: ReactPointerEvent) => { + const rect = ref.current?.getBoundingClientRect(); + if (!rect || rect.width === 0) return; + onScrub(sampleIndexAtFraction((event.clientX - rect.left) / rect.width, history.length)); + }; + + return ( + onScrub(null)} + > + + + {scrubIndex !== null ? : null} + + ); +} diff --git a/src/renderer/components/processes/process-actions.tsx b/src/renderer/components/processes/process-actions.tsx index bbf4e25..9b2c1e2 100644 --- a/src/renderer/components/processes/process-actions.tsx +++ b/src/renderer/components/processes/process-actions.tsx @@ -33,12 +33,11 @@ export function ProcessActions({ return (
          -

          - {message} -

          + {message ? ( +

          + {message} +

          + ) : null}
          = { */ export function ProcessDetailView({ detail, + history, sort, actions, actionsBusy, @@ -49,6 +54,7 @@ export function ProcessDetailView({ onRunAction, }: { detail: ProcessDetail + history: HistorySample[] sort: SortMode actions: ActionState[] actionsBusy: boolean @@ -148,6 +154,8 @@ export function ProcessDetailView({ )} + + {grouped ? ( - ) : ( - - )} + ) : null}
          {detail.system ? null : ( @@ -173,17 +179,44 @@ export function ProcessDetailView({ ); } -/** - * The metric value for a single-process detail (no members): one row in the - * slot the group's Members header occupies. Not collapsible or drillable. - */ -function SingleProcessMetric({ detail }: { detail: ProcessDetail }) { +/** Recent trend for the selected process or group under the active metric. */ +function ProcessMetricGraph({ detail, history }: { detail: ProcessDetail; history: HistorySample[] }) { + const isCpu = detail.totalSort === "cpu"; + const [scrubIndex, setScrubIndex] = useState(null); + + const format = isCpu ? formatCpuPercentPrecise : (value: number) => formatBytes(value, true); + + const scrubbed = scrubIndex !== null ? history[scrubIndex] ?? null : null; + const valueText = scrubIndex !== null + ? scrubbed !== null + ? format(scrubbed) + : "--" + : detail.totalValue !== null + ? format(detail.totalValue) + : metricValueText(detail.total.state, detail.total.text); + + const scrubPercent = + scrubIndex !== null + ? ((HISTORY_CAPACITY - history.length + scrubIndex + 0.5) / HISTORY_CAPACITY) * 100 + : null; + return ( -
          - - {TOTAL_LABEL[detail.totalSort]} - - +
          + + + +
          + {isCpu ? ( + + ) : ( + + )} + {scrubbed !== null && scrubPercent !== null ? ( + + {format(scrubbed)} + + ) : null} +
          ); } diff --git a/src/renderer/components/processes/process-explorer-view.tsx b/src/renderer/components/processes/process-explorer-view.tsx index f1de018..ba2c167 100644 --- a/src/renderer/components/processes/process-explorer-view.tsx +++ b/src/renderer/components/processes/process-explorer-view.tsx @@ -7,6 +7,7 @@ import { ProcessList } from "@/components/processes/process-list"; import { ProcessSearchField } from "@/components/processes/process-search-field"; import { ProcessSortControl } from "@/components/processes/process-sort-control"; import { useProcessActions } from "@/components/processes/use-process-actions"; +import { useProcessHistories } from "@/components/processes/use-process-histories"; import { projectProcessList, resolveSelection, @@ -122,6 +123,7 @@ export function ProcessExplorerView({ active }: { active: boolean }) { return undefined; }, [snapshot, sort, selectionStack]); + const readHistory = useProcessHistories(snapshot); const { actions, actionsBusy, actionMessage, runAction } = useProcessActions( detail, pull, @@ -162,6 +164,7 @@ export function ProcessExplorerView({ active }: { active: boolean }) { ProcessHistory { + const [trailsByKey, setTrailsByKey] = useState>(() => new Map()); + const trackedKeys = useRef(new Set()); + const lastRevision = useRef(null); + + useEffect(() => { + if (lastRevision.current === snapshot.revision) { + return; + } + lastRevision.current = snapshot.revision; + if (trackedKeys.current.size === 0) { + return; + } + const samples = sampleMetricsByKey(snapshot); + for (const key of trackedKeys.current) { + if (!samples.has(key)) { + trackedKeys.current.delete(key); + } + } + setTrailsByKey((previous) => { + const next = new Map(); + for (const key of trackedKeys.current) { + const sample = samples.get(key); + if (sample === undefined) { + continue; + } + const prior = previous.get(key); + next.set(key, { + cpu: pushSample(prior?.cpu ?? [], sample.cpu), + memory: pushSample(prior?.memory ?? [], sample.memory), + }); + } + return next; + }); + }, [snapshot]); + + return useCallback( + (key: string, sort: SortMode): ProcessHistory => { + trackedKeys.current.add(key); + const trails = trailsByKey.get(key); + return { history: (sort === "cpu" ? trails?.cpu : trails?.memory) ?? [] }; + }, + [trailsByKey], + ); +} diff --git a/src/renderer/domain/process-detail.ts b/src/renderer/domain/process-detail.ts index d6b7647..187e507 100644 --- a/src/renderer/domain/process-detail.ts +++ b/src/renderer/domain/process-detail.ts @@ -92,6 +92,8 @@ export interface ProcessDetail { user: DetailField; /** The group's total for the selected metric, with detail precision. */ total: DetailField; + /** Raw active-metric total for the trend graph. */ + totalValue: number | null; /** Which metric {@link total} reflects, for the "Total CPU"/"Total RAM" label. */ totalSort: SortMode; memberCount: number; @@ -166,6 +168,20 @@ function sumGroup( return { state: anyPending ? "pending" : "unavailable" }; } +/** Raw counterpart to {@link sumGroup}; `null` means the graph should draw a gap. */ +function sumGroupValue(members: ProcessRow[], read: (row: ProcessRow) => MetricCell): number | null { + let sum = 0; + let hasValue = false; + for (const row of members) { + const value = read(row).value; + if (value !== undefined) { + sum += value; + hasValue = true; + } + } + return hasValue ? sum : null; +} + function rowThreadCount(row: ProcessRow): MetricCell { const threads = row.threadCount; if (threads && threads.status === FieldStatus.FIELD_STATUS_OK) { @@ -260,6 +276,7 @@ export function buildProcessDetail(group: ProcessGroup, sort: SortMode, icons: I cpuTime: sumGroup(group.members, rowCpuTime, formatCpuTime), user: detailUser(representative), total: sumGroup(group.members, read, (value) => formatDetailMetric(value, sort)), + totalValue: sumGroupValue(group.members, read), totalSort: sort, memberCount: group.memberCount, members, diff --git a/src/renderer/domain/process-list.ts b/src/renderer/domain/process-list.ts index c040596..6c90325 100644 --- a/src/renderer/domain/process-list.ts +++ b/src/renderer/domain/process-list.ts @@ -493,6 +493,42 @@ export function projectProcessList( return groups.slice(0, DISPLAY_LIMIT); } +/** Raw CPU/memory readings for one graph tick; `null` draws as a gap. */ +export interface MetricSample { + cpu: number | null; + memory: number | null; +} + +function addSample(current: number | null, cell: MetricCell): number | null { + if (cell.value === undefined) { + return current; + } + return (current ?? 0) + cell.value; +} + +/** Samples graph values under the same keys {@link resolveSelection} uses. */ +export function sampleMetricsByKey(snapshot: ProcessSnapshot): Map { + const samples = new Map(); + + const fold = (key: string, row: ProcessRow) => { + const existing = samples.get(key); + const cpu = addSample(existing?.cpu ?? null, rowCpu(row)); + const memory = addSample(existing?.memory ?? null, rowMemory(row)); + samples.set(key, { cpu, memory }); + }; + + for (const row of snapshot.processes) { + const groupKeyValue = rowGroupKey(row); + fold(groupKeyValue, row); + const identityKey = rowIdentityKey(row); + if (identityKey !== groupKeyValue) { + fold(identityKey, row); + } + } + + return samples; +} + /** * Reorders freshly ranked items to match a previously rendered identity order, * so a list can pin row positions while the pointer or focus is inside it (a diff --git a/src/renderer/domain/sample-history.ts b/src/renderer/domain/sample-history.ts index ab6c7eb..a386317 100644 --- a/src/renderer/domain/sample-history.ts +++ b/src/renderer/domain/sample-history.ts @@ -7,6 +7,9 @@ export const HISTORY_CAPACITY = 60; +/** Scalar graph sample; `null` draws as a gap rather than a fake 0. */ +export type HistorySample = number | null; + /** Appends `sample`, keeping at most `capacity` newest entries (oldest dropped). */ export function pushSample(history: T[], sample: T, capacity = HISTORY_CAPACITY): T[] { const next = [...history, sample]; diff --git a/tests/unit/process-detail.test.ts b/tests/unit/process-detail.test.ts index 1ce83cf..a8b17b8 100644 --- a/tests/unit/process-detail.test.ts +++ b/tests/unit/process-detail.test.ts @@ -109,6 +109,7 @@ describe("buildProcessDetail - totals", () => { expect(detail.total.state).toBe("ok"); // 4% + 8% = 12%, detail precision (two decimals). expect(detail.total.text).toBe("12.00%"); + expect(detail.totalValue).toBe(12); }); it("sums the selected metric across members (memory)", () => { @@ -116,6 +117,7 @@ describe("buildProcessDetail - totals", () => { expect(detail.totalSort).toBe("memory"); // 300 MB + 150 MB = 450 MB, detail precision (one extra decimal). expect(detail.total.text).toBe("450.0 MB"); + expect(detail.totalValue).toBe(450 * MB); }); it("sums thread count and CPU time across members", () => { @@ -196,6 +198,7 @@ describe("buildProcessDetail - group total mixed states", () => { const detail = buildProcessDetail(groupOf(rows, "app:/Applications/App.app"), "cpu", NO_ICONS); expect(detail.total.state).toBe("ok"); expect(detail.total.text).toBe("6.00%"); + expect(detail.totalValue).toBe(6); }); it("reports the total pending when no member is OK but one is still pending", () => { @@ -215,5 +218,6 @@ describe("buildProcessDetail - group total mixed states", () => { ]; const detail = buildProcessDetail(groupOf(rows, "app:/Applications/App.app"), "cpu", NO_ICONS); expect(detail.total.state).toBe("unavailable"); + expect(detail.totalValue).toBeNull(); }); }); diff --git a/tests/unit/process-list.test.ts b/tests/unit/process-list.test.ts index d4b91ec..55fce14 100644 --- a/tests/unit/process-list.test.ts +++ b/tests/unit/process-list.test.ts @@ -15,6 +15,7 @@ import { rowMetric, rowNotResponding, rowPid, + sampleMetricsByKey, singleProcessGroup, SYSTEM_GROUP_KEY, } from "@/domain/process-list"; @@ -473,3 +474,35 @@ describe("projectProcessList - System group", () => { expect(resolved?.name).toBe("System"); }); }); + +describe("sampleMetricsByKey", () => { + it("sums a group's members under the group key, with per-member identity keys", () => { + const rows = [ + makeRow({ pid: 100, bundlePath: "/Applications/Chrome.app", startedAtUnixMs: 1, cpuPercent: 4, footprintBytes: 300 * MB }), + makeRow({ pid: 200, bundlePath: "/Applications/Chrome.app", startedAtUnixMs: 2, cpuPercent: 8, footprintBytes: 150 * MB }), + ]; + const samples = sampleMetricsByKey(makeSnapshot(rows)); + + expect(samples.get("app:/Applications/Chrome.app")).toEqual({ cpu: 12, memory: 450 * MB }); + expect(samples.get("pid:100:1")).toEqual({ cpu: 4, memory: 300 * MB }); + expect(samples.get("pid:200:2")).toEqual({ cpu: 8, memory: 150 * MB }); + }); + + it("keys an ungrouped process once (group key == identity)", () => { + const rows = [makeRow({ pid: 321, commandName: "tool", startedAtUnixMs: 5, cpuPercent: 7, footprintBytes: 20 * MB })]; + const samples = sampleMetricsByKey(makeSnapshot(rows)); + + expect(samples.get("pid:321:5")).toEqual({ cpu: 7, memory: 20 * MB }); + expect(samples.size).toBe(1); + }); + + it("records an unreadable metric as null, not a fabricated 0", () => { + const rows = [ + makeRow({ pid: 10, commandName: "x", startedAtUnixMs: 1, cpuStatus: FieldStatus.FIELD_STATUS_UNAVAILABLE, footprintBytes: 5 * MB }), + ]; + const sample = sampleMetricsByKey(makeSnapshot(rows)).get("pid:10:1"); + + expect(sample?.cpu).toBeNull(); + expect(sample?.memory).toBe(5 * MB); + }); +}); From b54acb19d5c4c2a98b154849e6473e0630329073 Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Wed, 17 Jun 2026 15:49:11 +0300 Subject: [PATCH 15/16] Adjust spacing in process detail layout --- .../components/processes/process-detail.tsx | 33 ++++--------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/src/renderer/components/processes/process-detail.tsx b/src/renderer/components/processes/process-detail.tsx index 7d2a1a3..a8a160d 100644 --- a/src/renderer/components/processes/process-detail.tsx +++ b/src/renderer/components/processes/process-detail.tsx @@ -95,7 +95,7 @@ export function ProcessDetailView({
          -
          +
          {detail.system ? null : ( -
          +
          @@ -205,7 +204,7 @@ function ProcessMetricGraph({ detail, history }: { detail: ProcessDetail; histor -
          +
          {isCpu ? ( ) : ( @@ -297,21 +296,6 @@ function HeaderStat({ ); } -/** Renders a {@link DetailField} value with the ok/pending/unavailable rule. */ -function MetricValue({ metric, className }: { metric: DetailField; className?: string }) { - return ( - - {metricValueText(metric.state, metric.text)} - - ); -} - /** A labeled detail field: a quiet uppercase label, the value below. */ function Field({ label, children }: { label: string; children: ReactNode }) { return ( @@ -374,21 +358,17 @@ function ScrollableValue({ } /** - * The expandable Members section for a multi-process app. The disclosure - * header carries the group's selected-metric total on the right; toggling it - * reveals the member processes (ranked by the active metric), each drillable - * into its own detail. Starts expanded; scrolls within a bounded box. + * The expandable Members section for a multi-process app. Rows are ranked by + * the active metric and drill into individual process details. */ function Members({ members: rankedMembers, memberCount, - total, resetKey, onOpenMember, }: { members: DetailMember[] memberCount: number - total: DetailField /** Changes when the drilled target or sort changes, dropping any stale pin. */ resetKey: string onOpenMember: (pid: number, startedAtUnixMs?: number) => void @@ -399,7 +379,7 @@ function Members({ const members = useOrderPin(rankedMembers, memberKey, pointerInside || focusInside, resetKey); return ( -
          +
          From 861b0c69364a642990613fde458f05cdc9304143 Mon Sep 17 00:00:00 2001 From: Danil-Didkovskiy Date: Wed, 17 Jun 2026 15:59:08 +0300 Subject: [PATCH 16/16] Adjust spacing in process detail layout --- src/renderer/components/processes/process-actions.tsx | 2 +- src/renderer/components/processes/process-detail.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/processes/process-actions.tsx b/src/renderer/components/processes/process-actions.tsx index 9b2c1e2..6e6030e 100644 --- a/src/renderer/components/processes/process-actions.tsx +++ b/src/renderer/components/processes/process-actions.tsx @@ -32,7 +32,7 @@ export function ProcessActions({ ); return ( -
          +
          {message ? (

          {message} diff --git a/src/renderer/components/processes/process-detail.tsx b/src/renderer/components/processes/process-detail.tsx index a8a160d..a66260d 100644 --- a/src/renderer/components/processes/process-detail.tsx +++ b/src/renderer/components/processes/process-detail.tsx @@ -127,7 +127,7 @@ export function ProcessDetailView({ {detail.system ? null : ( -

          +
          -
          +
          {isCpu ? ( ) : (