From 9258f4f4179ab53294fc2392634e689941cfcace Mon Sep 17 00:00:00 2001 From: Sahil Ahuja Date: Tue, 21 Jul 2026 11:13:32 +0530 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Sessions-Pane=20Server-Group=20Head?= =?UTF-8?q?er=20=E2=80=94=20Color-Picker=20and=20Close=20Buttons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a three-button action cluster (color palette, plus, close) to the SESSIONS-pane server-group header, reusing the SwatchPopover write seam and the lifted onKillServer confirmation flow so the tinted header (t1ca) can also change and kill the server it represents. --- app/frontend/src/app.tsx | 9 +- .../src/components/board/board-page.tsx | 10 +- .../src/components/sidebar/index.test.tsx | 178 +++++++++++++++++- app/frontend/src/components/sidebar/index.tsx | 144 ++++++++++++-- docs/memory/run-kit/ui-patterns.md | 18 +- .../.history.jsonl | 19 ++ .../.status.yaml | 55 ++++++ .../intake.md | 114 +++++++++++ .../plan.md | 155 +++++++++++++++ .../.history.jsonl | 0 .../.status.yaml | 0 .../intake.md | 0 .../plan.md | 0 fab/changes/archive/index.md | 1 + 14 files changed, 677 insertions(+), 26 deletions(-) create mode 100644 fab/changes/260721-x4sf-sessions-header-color-close-actions/.history.jsonl create mode 100644 fab/changes/260721-x4sf-sessions-header-color-close-actions/.status.yaml create mode 100644 fab/changes/260721-x4sf-sessions-header-color-close-actions/intake.md create mode 100644 fab/changes/260721-x4sf-sessions-header-color-close-actions/plan.md rename fab/changes/{ => archive/2026/07}/260720-t1ca-sidebar-server-group-header-tint/.history.jsonl (100%) rename fab/changes/{ => archive/2026/07}/260720-t1ca-sidebar-server-group-header-tint/.status.yaml (100%) rename fab/changes/{ => archive/2026/07}/260720-t1ca-sidebar-server-group-header-tint/intake.md (100%) rename fab/changes/{ => archive/2026/07}/260720-t1ca-sidebar-server-group-header-tint/plan.md (100%) diff --git a/app/frontend/src/app.tsx b/app/frontend/src/app.tsx index 5aff8ee9..da850ff3 100644 --- a/app/frontend/src/app.tsx +++ b/app/frontend/src/app.tsx @@ -2368,6 +2368,13 @@ function AppShell() { }, [server, handleCreateSessionInstant, executeCreateSessionInstant], ); + // Stable kill-server handler (260721-x4sf): `onKillServer` now threads into + // the memoized `ServerGroup` header cluster, so an inline arrow here would + // hand every group a fresh identity per SSE tick and defeat the memo skip. + // `setKillServerTarget` is a stable state setter — no deps. + const handleSidebarKillServer = useCallback((name: string) => { + setKillServerTarget(name); + }, []); // Waiting-badge click (260714-r7rq): navigate to the NEXT waiting window // within the clicked session's scope, reusing the `nextWaitingTarget` cycle @@ -2510,7 +2517,7 @@ function AppShell() { onCreateSession={handleSidebarCreateSession} onSpawnAgent={handleOpenSpawnAgent} onCreateServer={() => setShowCreateServerDialog(true)} - onKillServer={(name) => setKillServerTarget(name)} + onKillServer={handleSidebarKillServer} onSidebarResizeStart={isMobile ? undefined : (e) => handleDragStart(e.clientX)} /> ); diff --git a/app/frontend/src/components/board/board-page.tsx b/app/frontend/src/components/board/board-page.tsx index 8dae06f2..c7c4b413 100644 --- a/app/frontend/src/components/board/board-page.tsx +++ b/app/frontend/src/components/board/board-page.tsx @@ -1040,6 +1040,14 @@ function BoardPageContent({ name }: { name: string }) { [navigate, isMobile, setSidebarOpen], ); + // Stable kill-server handler — same R6a reasoning as handleSelectWindow: + // `onKillServer` threads into the memoized `ServerGroup` header cluster + // (260721-x4sf), so an inline arrow would defeat the memo skip on every SSE + // tick. `setKillServerTarget` is a stable state setter — no deps. + const handleSidebarKillServer = useCallback((name: string) => { + setKillServerTarget(name); + }, []); + // Sidebar element shared between desktop grid placement and mobile overlay. // `currentServer = null` because the board route has no `$server` param — // no group is marked current and all server groups follow persisted toggles. @@ -1052,7 +1060,7 @@ function BoardPageContent({ name }: { name: string }) { onCreateWindow={handleCreateWindow} onCreateSession={handleCreateSession} onCreateServer={() => setShowCreateServerDialog(true)} - onKillServer={(n) => setKillServerTarget(n)} + onKillServer={handleSidebarKillServer} /> ); diff --git a/app/frontend/src/components/sidebar/index.test.tsx b/app/frontend/src/components/sidebar/index.test.tsx index 237bab08..b43e7537 100644 --- a/app/frontend/src/components/sidebar/index.test.tsx +++ b/app/frontend/src/components/sidebar/index.test.tsx @@ -9,7 +9,7 @@ import { ThemeProvider } from "@/contexts/theme-context"; import { ChromeProvider } from "@/contexts/chrome-context"; import { ToastProvider } from "@/components/toast"; import { useWindowStore } from "@/store/window-store"; -import { getAllServerColors } from "@/api/client"; +import { getAllServerColors, setServerColor } from "@/api/client"; import { computeRowTints, computeRowBorders, @@ -87,6 +87,9 @@ type RenderOpts = { hostMetricsConnected?: boolean; /** Host-global metrics snapshot fed to HostMetricsProvider (defaults null). */ hostMetrics?: MetricsSnapshot | null; + /** Override the Sidebar's onKillServer prop (x4sf) — the header ✕ routes + * through it; tests assert the invocation, never a direct kill call. */ + onKillServer?: (name: string) => void; }; /** Mounts BoardPage's registration seam inside the provider (260720-zx4i). */ @@ -132,7 +135,7 @@ function renderSidebar(opts: RenderOpts = {}) { onCreateWindow={vi.fn()} onCreateSession={vi.fn()} onCreateServer={vi.fn()} - onKillServer={vi.fn()} + onKillServer={opts.onKillServer ?? vi.fn()} /> @@ -1069,3 +1072,174 @@ describe("Sidebar — tinted server-group header fill (t1ca)", () => { ).toBeInTheDocument(); }); }); + +describe("Sidebar — server-group header action cluster (x4sf)", () => { + // The header hosts a three-button server action cluster — palette, plus, + // close, in that fixed order — reusing the SERVER-tile machinery wholesale: + // SwatchPopover + the shared onServerColorChange seam for color, the lifted + // onKillServer confirmation flow for kill. Queries are scoped WITHIN the + // header container ([data-server]) because the SERVER-panel tiles carry the + // same aria wording for the same actions. + const palette = DEFAULT_DARK_THEME.palette; + const tints = computeRowTints(palette); + const borders = computeRowBorders(palette, DEFAULT_DARK_THEME.category); + + /** jsdom normalizes inline style colors to `rgb(r, g, b)`. */ + function rgb(hex: string): string { + const h = hex.replace("#", ""); + return `rgb(${parseInt(h.slice(0, 2), 16)}, ${parseInt(h.slice(2, 4), 16)}, ${parseInt(h.slice(4, 6), 16)})`; + } + + function headerContainer(server: string): HTMLElement { + const el = document.querySelector(`[data-server='${server}']`); + expect(el, `header container for ${server}`).toBeTruthy(); + return el!; + } + + /** Render and flush the getAllServerColors effect promise. */ + async function renderWithColors( + colors: Record, + opts: { currentServer?: string; onKillServer?: (name: string) => void } = {}, + ) { + vi.mocked(getAllServerColors).mockResolvedValue(colors); + renderSidebar({ currentServer: opts.currentServer ?? "primary", onKillServer: opts.onKillServer }); + await act(async () => {}); + } + + afterEach(() => { + // Restore the file-default mocks so this block's state never leaks. + vi.mocked(getAllServerColors).mockResolvedValue({}); + vi.mocked(setServerColor).mockClear(); + }); + + it("renders the cluster in palette → plus → close DOM order after the toggle", async () => { + await renderWithColors({ alpha: "4" }); + + const buttons = within(headerContainer("alpha")).getAllByRole("button"); + expect(buttons).toHaveLength(4); + expect(buttons[0]).toHaveAccessibleName(/(Expand|Collapse) alpha sessions/); + expect(buttons[1]).toHaveAccessibleName("Set color for server alpha"); + expect(buttons[2]).toHaveAccessibleName("New session on alpha"); + expect(buttons[3]).toHaveAccessibleName("Kill server alpha"); + }); + + it("hover-reveals the palette with the coarse touch fallback; + and ✕ stay always visible", async () => { + await renderWithColors({ alpha: "4" }); + + const container = headerContainer("alpha"); + // The reveal is driven by group-hover on the header container itself. + expect(container.className).toContain("group"); + + const paletteBtn = within(container).getByRole("button", { name: "Set color for server alpha" }); + for (const cls of ["opacity-0", "group-hover:opacity-100", "coarse:opacity-100"]) { + expect(paletteBtn.className).toContain(cls); + } + const plus = within(container).getByRole("button", { name: "New session on alpha" }); + const close = within(container).getByRole("button", { name: "Kill server alpha" }); + for (const btn of [plus, close]) { + expect(btn.className).not.toContain("opacity-0"); + expect(btn.className).not.toContain("group-hover:opacity-100"); + } + }); + + it("cluster rest color follows the header text treatment (accent wrapper, inherited by buttons)", async () => { + await renderWithColors({ primary: "4", alpha: "1" }); + + // Non-current: the cluster wrapper carries the contrast-guarded accent as + // an inline color; the buttons themselves carry NO inline color so their + // hover: classes (text-text-primary / text-red-400) can win on hover. + const alphaPalette = within(headerContainer("alpha")).getByRole("button", { + name: "Set color for server alpha", + }); + const alphaWrapper = alphaPalette.parentElement as HTMLElement; + expect(alphaWrapper.style.color).toBe(rgb(borders.get("1")!)); + const alphaClose = within(headerContainer("alpha")).getByRole("button", { + name: "Kill server alpha", + }); + expect(alphaClose.style.color).toBe(""); + expect(alphaClose.className).toContain("hover:text-red-400"); + + // Current: brightest text via class, no inline accent. + const primaryPalette = within(headerContainer("primary")).getByRole("button", { + name: "Set color for server primary", + }); + const primaryWrapper = primaryPalette.parentElement as HTMLElement; + expect(primaryWrapper.className).toContain("text-text-primary"); + expect(primaryWrapper.style.color).toBe(""); + }); + + it("palette toggle opens a color-only SwatchPopover portalled to document.body", async () => { + await renderWithColors({ alpha: "4" }); + + const container = headerContainer("alpha"); + fireEvent.click(within(container).getByRole("button", { name: "Set color for server alpha" })); + + // Color-only picker (no marker column) — distinguished from the SERVER + // panel's role=listbox tile grid by its accessible name. + const popover = screen.getByRole("listbox", { name: "Color picker" }); + // Portalled: escapes the header (and the sessions list's overflow clip). + expect(container.contains(popover)).toBe(false); + expect(document.body.contains(popover)).toBe(true); + }); + + it("a swatch pick funnels through the shared seam: optimistic tint repaint + POST, then closes", async () => { + await renderWithColors({}); // alpha starts uncolored (gray sentinel) + + const container = headerContainer("alpha"); + expect(container.style.backgroundColor).toBe(rgb(tints.get(UNCOLORED_SELECTED_KEY)!.base)); + + fireEvent.click(within(container).getByRole("button", { name: "Set color for server alpha" })); + const popover = screen.getByRole("listbox", { name: "Color picker" }); + fireEvent.click(within(popover).getByRole("option", { name: "Color blue" })); + + // The single write seam maps the family to its legacy descriptor ("4") + // and the shared handler POSTs + repaints the header tint optimistically + // (non-current ⇒ base shade) without waiting for any poll. + expect(vi.mocked(setServerColor)).toHaveBeenCalledExactlyOnceWith("alpha", "4"); + expect(container.style.backgroundColor).toBe(rgb(tints.get("4")!.base)); + expect(screen.queryByRole("listbox", { name: "Color picker" })).not.toBeInTheDocument(); + }); + + it("Clear color clears the optimistic entry back to the gray sentinel and POSTs null", async () => { + await renderWithColors({ alpha: "4" }); + + const container = headerContainer("alpha"); + expect(container.style.backgroundColor).toBe(rgb(tints.get("4")!.base)); + + fireEvent.click(within(container).getByRole("button", { name: "Set color for server alpha" })); + fireEvent.click( + within(screen.getByRole("listbox", { name: "Color picker" })).getByRole("option", { + name: "Clear color", + }), + ); + + expect(vi.mocked(setServerColor)).toHaveBeenCalledExactlyOnceWith("alpha", null); + expect(container.style.backgroundColor).toBe(rgb(tints.get(UNCOLORED_SELECTED_KEY)!.base)); + }); + + it("✕ invokes the lifted onKillServer prop with the server name (confirmation is the parent's)", async () => { + const onKillServer = vi.fn(); + await renderWithColors({ alpha: "4" }, { onKillServer }); + + fireEvent.click( + within(headerContainer("alpha")).getByRole("button", { name: "Kill server alpha" }), + ); + + expect(onKillServer).toHaveBeenCalledExactlyOnceWith("alpha"); + // No sidebar-owned dialog: the kill confirmation lives in the parent + // (app.tsx / board-page.tsx killServerTarget), so nothing renders here. + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("keeps the toggle dominant and the existing header semantics intact", async () => { + await renderWithColors({ alpha: "4" }); + + const container = headerContainer("alpha"); + const toggle = within(container).getByRole("button", { name: /Expand alpha sessions/ }); + expect(toggle.className).toContain("flex-1"); + fireEvent.click(toggle); + expect( + within(container).getByRole("button", { name: /Collapse alpha sessions/ }), + ).toHaveAttribute("aria-expanded", "true"); + }); +}); diff --git a/app/frontend/src/components/sidebar/index.tsx b/app/frontend/src/components/sidebar/index.tsx index 58cc829c..dfe7a423 100644 --- a/app/frontend/src/components/sidebar/index.tsx +++ b/app/frontend/src/components/sidebar/index.tsx @@ -1,4 +1,5 @@ -import { useState, useCallback, useRef, useEffect, useMemo, useReducer, memo } from "react"; +import { useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo, useReducer, memo } from "react"; +import { createPortal } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { killSession as killSessionApi, killWindow as killWindowApi, renameSession, moveWindow, moveWindowToSession, setSessionColor as setSessionColorApi, setWindowColor as setWindowColorApi, setWindowMarker as setWindowMarkerApi, getAllServerColors, setServerColor as setServerColorApi, setSessionOrder, type ServerInfo } from "@/api/client"; import { useSessionContext } from "@/contexts/session-context"; @@ -8,6 +9,8 @@ import { useOptimisticAction } from "@/hooks/use-optimistic-action"; import { useOptimisticContext } from "@/contexts/optimistic-context"; import { useToast } from "@/components/toast"; import { TypedLabel } from "@/components/typed-label"; +import { SwatchPopover } from "@/components/swatch-popover"; +import { PaletteIcon } from "./icons"; import { useTheme } from "@/contexts/theme-context"; import { computeRowTints, computeRowBorders, UNCOLORED_SELECTED_KEY } from "@/themes"; import type { ProjectSession } from "@/types"; @@ -1069,6 +1072,23 @@ export function Sidebar({ ); }, [addToast]); + // Server color write seam — the SINGLE implementation both the SERVER-panel + // tiles and the session-tree group headers funnel through (x4sf): optimistic + // `serverColors` update (the local repaint — server user-option mutations + // emit no control-mode event, so covered servers otherwise wait on the 12s + // safety poll) + POST + failure toast. Stable identity-arg callback so it + // rides the ServerGroup memo contract like the other row handlers. + const handleServerColorChange = useCallback((targetServer: string, c: string | null) => { + setServerColors((prev) => { + const next = { ...prev }; + if (c == null) { delete next[targetServer]; } else { next[targetServer] = c; } + return next; + }); + setServerColorApi(targetServer, c).catch((err) => + addToast(err.message || "Failed to set server color"), + ); + }, [addToast]); + // `current` scope narrows to the resolved current server. When no current // server resolves — board route (`currentServer === null`) or a // stale/deleted route param not in the list — fall back to showing all @@ -1101,16 +1121,7 @@ export function Sidebar({ onKillServer={onKillServer} onRefreshServers={refreshServers} onSidebarResizeStart={onSidebarResizeStart} - onServerColorChange={(targetServer, c) => { - setServerColors((prev) => { - const next = { ...prev }; - if (c == null) { delete next[targetServer]; } else { next[targetServer] = c; } - return next; - }); - setServerColorApi(targetServer, c).catch((err) => - addToast(err.message || "Failed to set server color"), - ); - }} + onServerColorChange={handleServerColorChange} /> {/* Sessions — flex-grows to fill remaining space; per-server groups inside */} @@ -1226,6 +1237,8 @@ export function Sidebar({ onWindowRenameKeyDown={handleWindowRenameKeyDown} onWindowRenameBlur={handleWindowRenameBlur} onSessionColorChange={handleSessionColorChange} + onServerColorChange={handleServerColorChange} + onKillServer={onKillServer} onWindowColorChange={handleWindowColorChange} onWindowMarkerChange={handleWindowMarkerChange} onWindowDragStart={handleDragStart} @@ -1381,6 +1394,12 @@ type ServerGroupProps = { onWindowRenameKeyDown: (e: React.KeyboardEvent) => void; onWindowRenameBlur: () => void; onSessionColorChange: (server: string, name: string, color: string | null) => void; + /** Server color write seam (x4sf) — the same shared handler `ServerPanel` + * receives (optimistic update + POST + toast). Stable identity. */ + onServerColorChange: (server: string, color: string | null) => void; + /** Kill-server request (x4sf) — routes to the parent's confirmation dialog + * (`killServerTarget` in app.tsx / board-page.tsx); never kills directly. */ + onKillServer: (name: string) => void; onWindowColorChange: (server: string, session: string, windowId: string, color: string | null) => void; onWindowMarkerChange: (server: string, session: string, windowId: string, marker: string | null) => void; onWindowDragStart: (e: React.DragEvent, server: string, session: string, index: number, windowId: string, name: string) => void; @@ -1446,6 +1465,8 @@ function ServerGroupInner(props: ServerGroupProps) { onWindowRenameKeyDown, onWindowRenameBlur, onSessionColorChange, + onServerColorChange, + onKillServer, onWindowColorChange, onWindowMarkerChange, onWindowDragStart, @@ -1554,6 +1575,31 @@ function ServerGroupInner(props: ServerGroupProps) { const headerBg = headerTint ? (isCurrent ? headerTint.selected : headerTint.base) : undefined; const headerHoverBg = headerTint && !isCurrent ? headerTint.hover : undefined; + // Header color picker (x4sf). Local per-group boolean — each group renders + // exactly one header, so the ServerPanel's keyed `colorPickerFor` map isn't + // needed (the session-row precedent). The popover is portalled to + // document.body with fixed coordinates anchored at the palette button, + // escaping the session list's overflow-y clip — the same reason (and flip + // heuristic) as the ServerTile portal in server-panel.tsx. + const [showColorPicker, setShowColorPicker] = useState(false); + const paletteBtnRef = useRef(null); + const [popoverPos, setPopoverPos] = useState<{ top: number; right: number } | null>(null); + useLayoutEffect(() => { + if (!showColorPicker || !paletteBtnRef.current) { + setPopoverPos(null); + return; + } + const rect = paletteBtnRef.current.getBoundingClientRect(); + const approxPopoverHeight = 100; // rough; fine for flip heuristic + const below = rect.bottom + 4; + const fitsBelow = below + approxPopoverHeight <= window.innerHeight; + const top = fitsBelow ? below : Math.max(4, rect.top - approxPopoverHeight - 4); + setPopoverPos({ + top, + right: Math.max(4, window.innerWidth - rect.right), + }); + }, [showColorPicker]); + return (
{server} - + + + + + {/* Color picker portalled to body so it escapes the sessions list's + overflow-y: auto clip (the ServerTile precedent). */} + {showColorPicker && popoverPos && createPortal( +
+ { + onServerColorChange(server, c); + setShowColorPicker(false); + }} + onClose={() => setShowColorPicker(false)} + /> +
, + document.body, + )} {isOpen && ( @@ -1795,7 +1895,13 @@ function ServerGroupInner(props: ServerGroupProps) { * identity-arg `useCallback` and the context Map/array props (`rowTints`, * `rowBorders`, `allBoards`, `pinnedSet`, `pinnedToBoard`, * `isPinnedToActiveBoardFor`) are stable refs — so a tick on server B does not - * re-render server A's group at all. The group whose `rawSessions`/order/ + * re-render server A's group at all. The header action-cluster props added by + * x4sf ride the same contract: `onServerColorChange` is a stable identity-arg + * `useCallback` in `Sidebar` (shared with `ServerPanel`) and `onKillServer` is + * the Sidebar prop passed through unchanged — itself stabilized at BOTH + * parents as an identity-arg `useCallback` (`handleSidebarKillServer` in + * app.tsx and board-page.tsx), so the stability holds end-to-end from the + * `setKillServerTarget` source. The group whose `rawSessions`/order/ * connection actually changed still re-renders (correct). (`boardsLoading` is a * primitive that flips true→false once when the board list finishes loading — * a legitimate one-time re-render of every group, not per-tick churn.) */ diff --git a/docs/memory/run-kit/ui-patterns.md b/docs/memory/run-kit/ui-patterns.md index d6c86d50..b64ed7c9 100644 --- a/docs/memory/run-kit/ui-patterns.md +++ b/docs/memory/run-kit/ui-patterns.md @@ -1153,7 +1153,7 @@ The sidebar tree does not re-render on every SSE session tick. The session SSE s **Scope chip**: A keyboard-focusable ` From 4ba23bce09b63007a313c1e1d39f82d05d42b865 Mon Sep 17 00:00:00 2001 From: Sahil Ahuja Date: Tue, 21 Jul 2026 11:21:47 +0530 Subject: [PATCH 4/4] Update review-pr status --- .../.history.jsonl | 2 ++ .../.status.yaml | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/fab/changes/260721-x4sf-sessions-header-color-close-actions/.history.jsonl b/fab/changes/260721-x4sf-sessions-header-color-close-actions/.history.jsonl index 318fc8df..99ca1e24 100644 --- a/fab/changes/260721-x4sf-sessions-header-color-close-actions/.history.jsonl +++ b/fab/changes/260721-x4sf-sessions-header-color-close-actions/.history.jsonl @@ -18,3 +18,5 @@ {"cmd":"fab-continue","event":"command","ts":"2026-07-21T05:39:27Z"} {"action":"enter","driver":"fab-fff","event":"stage-transition","stage":"ship","ts":"2026-07-21T05:42:29Z"} {"action":"enter","driver":"git-pr","event":"stage-transition","stage":"review-pr","ts":"2026-07-21T05:44:28Z"} +{"cmd":"git-pr-review","event":"command","ts":"2026-07-21T05:46:02Z"} +{"event":"review","result":"passed","ts":"2026-07-21T05:51:47Z"} diff --git a/fab/changes/260721-x4sf-sessions-header-color-close-actions/.status.yaml b/fab/changes/260721-x4sf-sessions-header-color-close-actions/.status.yaml index 03c8d28c..1a1163b1 100644 --- a/fab/changes/260721-x4sf-sessions-header-color-close-actions/.status.yaml +++ b/fab/changes/260721-x4sf-sessions-header-color-close-actions/.status.yaml @@ -10,7 +10,7 @@ progress: review: done hydrate: done ship: done - review-pr: active + review-pr: done plan: generated: true task_count: 5 @@ -34,7 +34,7 @@ stage_metrics: review: {started_at: "2026-07-21T05:34:50Z", driver: fab-fff, iterations: 2, completed_at: "2026-07-21T05:38:46Z"} hydrate: {started_at: "2026-07-21T05:38:46Z", driver: fab-fff, iterations: 1, completed_at: "2026-07-21T05:42:29Z"} ship: {started_at: "2026-07-21T05:42:29Z", driver: fab-fff, iterations: 1, completed_at: "2026-07-21T05:44:28Z"} - review-pr: {started_at: "2026-07-21T05:44:28Z", driver: git-pr, iterations: 1} + review-pr: {started_at: "2026-07-21T05:44:28Z", driver: git-pr, iterations: 1, completed_at: "2026-07-21T05:51:47Z"} prs: - https://github.com/sahil87/run-kit/pull/432 change_type_source: explicit @@ -54,4 +54,4 @@ true_impact: computed_at_stage: ship summary: SESSIONS-pane server-group header gains a palette/plus/close action cluster sharing the ServerTile color write seam and lifted kill-confirmation flow # true_impact: lazily created on first stage-finish that computes it (no placeholder here). -last_updated: 2026-07-21T05:44:28Z +last_updated: 2026-07-21T05:51:47Z