Merge remote-tracking branch 'origin/main' into brennanb2025/status-check

This commit is contained in:
Brennan Benson
2026-08-05 20:49:55 -07:00
21 changed files with 673 additions and 385 deletions
@@ -0,0 +1,64 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { detectAgentPermission } from './mobile-native-chat-permission'
import type { parseAgentQuestion } from './mobile-native-chat-question'
import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts'
import type { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
export type MobileNativeChatController = {
/** Whether a tab's effective view is chat (per-tab override, else the default). */
isTabChatView: (tabId: string) => boolean
toggleTabChatView: (tabId: string) => void
showNativeChat: boolean
showNativeChatRef: MutableRefObject<boolean>
/** Resolved agent for the active chat tab (names the empty-state copy). */
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: MobileNativeChatPendingMessage[]
chatImagePreviewsByMessageId: Record<string, string[]>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
/** Agent mid-turn, regardless of whether chat is the visible view. */
nativeChatStreamLive: boolean
/** Host/workspace/tab/session scope for stateful streaming suppression. */
nativeChatStreamScopeKey: string
nativeChatPermission: ReturnType<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
/** The pending ask, already null while dismissed (dismissal lives here so it
* survives the chat-view subtree unmounting on a view toggle). */
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
/** Stable key for the current ask card (keys the card component). */
nativeChatAskKey: string | null
/** Hide the current ask until a genuinely different question arrives. */
dismissNativeChatAsk: () => void
handleNativeChatAnswerAsk: (
prompt: AskPrompt,
selections: AskAnswerSelection[]
) => Promise<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
/** Outcome-preserving send: callers that pasted terminal input beforehand
* (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a
* caller passes its own `deadline` so the paste it already spent and this text
* body share one budget instead of holding the composer for two. */
handleNativeChatSendWithOutcome: (
text: string,
images?: string[],
deadline?: number
) => Promise<MobileNativeChatSendOutcome>
/** Launch-context text still parked on the agent's TUI input line, or null.
* Image sends read it to size their leading clear (one Ctrl+U per line). */
readSeededLaunchDraft: () => string | null
/** Model/session-option pickers for the composer, or null when the active
* agent has no session-option catalog. */
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
}
@@ -1,101 +1,29 @@
import {
useCallback,
useLayoutEffect,
useRef,
type Dispatch,
type MutableRefObject,
type SetStateAction
} from 'react'
import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react'
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
parseAskFromStatus,
type AskAnswerSelection,
type AskPrompt
} from './mobile-native-chat-ask'
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
import { detectAgentPermission } from './mobile-native-chat-permission'
import { parseAgentQuestion } from './mobile-native-chat-question'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask'
import {
useMobileNativeChatDrafts,
type MobileNativeChatPendingMessage
} from './use-mobile-native-chat-drafts'
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
import { useThrottledLatestValue } from './use-throttled-latest-value'
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
export type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
const NATIVE_CHAT_STREAM_THROTTLE_MS = 50
export type MobileNativeChatController = {
/** Whether a tab's effective view is chat (per-tab override, else the default). */
isTabChatView: (tabId: string) => boolean
toggleTabChatView: (tabId: string) => void
showNativeChat: boolean
showNativeChatRef: MutableRefObject<boolean>
/** Resolved agent for the active chat tab (names the empty-state copy). */
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: MobileNativeChatPendingMessage[]
chatImagePreviewsByMessageId: Record<string, string[]>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
/** Agent mid-turn, regardless of whether chat is the visible view. */
nativeChatStreamLive: boolean
/** Host/workspace/tab/session scope for stateful streaming suppression. */
nativeChatStreamScopeKey: string
nativeChatPermission: ReturnType<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
/** The pending ask, already null while dismissed (dismissal lives here so it
* survives the chat-view subtree unmounting on a view toggle). */
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
/** Stable key for the current ask card (keys the card component). */
nativeChatAskKey: string | null
/** Hide the current ask until a genuinely different question arrives. */
dismissNativeChatAsk: () => void
handleNativeChatAnswerAsk: (
prompt: AskPrompt,
selections: AskAnswerSelection[]
) => Promise<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
/** Outcome-preserving send: callers that pasted terminal input beforehand
* (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a
* caller passes its own `deadline` so the paste it already spent and this text
* body share one budget instead of holding the composer for two. */
handleNativeChatSendWithOutcome: (
text: string,
images?: string[],
deadline?: number
) => Promise<MobileNativeChatSendOutcome>
/** Launch-context text still parked on the agent's TUI input line, or null.
* Image sends read it to size their leading clear (one Ctrl+U per line). */
readSeededLaunchDraft: () => string | null
/** Model/session-option pickers for the composer, or null when the active
* agent has no session-option catalog. */
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
}
/** Owns mobile native-chat state and teardown outside the already dense session
* route. The route remains responsible only for choosing and rendering the view. */
export function useMobileNativeChatController(args: {
@@ -0,0 +1,167 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WebSocket } from 'ws'
import { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat'
afterEach(() => {
vi.useRealTimers()
})
const INTERVAL_MS = 100
function makeSocket(): WebSocket {
return { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket
}
describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => {
it('keeps a client whose pong is merely delayed past one probe window', async () => {
vi.useFakeTimers()
let now = 1_000
const socket = makeSocket()
const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now)
heartbeat.noteAlive(socket)
heartbeat.start(() => [socket]) // probe #1
heartbeat.noteAlive(socket) // pongs probe #1
// A transient blackhole: probe #2 goes out and its pong is stuck in the network.
now += INTERVAL_MS
await vi.advanceTimersByTimeAsync(INTERVAL_MS)
expect(socket.ping).toHaveBeenCalledTimes(2)
expect(socket.terminate).not.toHaveBeenCalled()
// The path recovers and the delayed pong lands right after the next sweep would have run.
now += INTERVAL_MS
await vi.advanceTimersByTimeAsync(INTERVAL_MS)
heartbeat.noteAlive(socket)
// One unanswered probe is not evidence the peer is gone: it must still be connected,
// and it must have been re-probed rather than reaped.
expect(socket.terminate).not.toHaveBeenCalled()
expect(socket.ping).toHaveBeenCalledTimes(3)
heartbeat.stop()
})
it('reaps only after consecutive probes go unanswered, counted not timed', async () => {
vi.useFakeTimers()
let now = 1_000
const socket = makeSocket()
const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now)
heartbeat.noteAlive(socket)
heartbeat.start(() => [socket])
heartbeat.noteAlive(socket)
const missesBeforeReap: number[] = []
for (let probe = 0; probe < 6; probe += 1) {
now += INTERVAL_MS
await vi.advanceTimersByTimeAsync(INTERVAL_MS)
missesBeforeReap.push(
(socket.terminate as unknown as ReturnType<typeof vi.fn>).mock.calls.length
)
}
// Silence must be tolerated for more than a single probe, and must eventually end the socket.
expect(missesBeforeReap[0]).toBe(0)
expect(missesBeforeReap[1]).toBe(0)
expect(missesBeforeReap.at(-1)).toBeGreaterThan(0)
heartbeat.stop()
})
it('lets any proof of life reset accumulated misses', async () => {
vi.useFakeTimers()
let now = 1_000
const socket = makeSocket()
const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now)
heartbeat.noteAlive(socket)
heartbeat.start(() => [socket])
heartbeat.noteAlive(socket)
// Two silent probes, then a single inbound frame, repeated well past any fixed budget.
for (let cycle = 0; cycle < 5; cycle += 1) {
now += INTERVAL_MS
await vi.advanceTimersByTimeAsync(INTERVAL_MS)
now += INTERVAL_MS
await vi.advanceTimersByTimeAsync(INTERVAL_MS)
heartbeat.noteAlive(socket)
}
expect(socket.terminate).not.toHaveBeenCalled()
heartbeat.stop()
})
it('charges no miss for a paused tick but keeps the ones already banked', async () => {
vi.useFakeTimers()
let now = 1_000
const socket = makeSocket()
const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now)
heartbeat.noteAlive(socket)
heartbeat.start(() => [socket])
heartbeat.noteAlive(socket)
const sweep = async (): Promise<void> => {
now += INTERVAL_MS
await vi.advanceTimersByTimeAsync(INTERVAL_MS)
}
const reaped = (candidate: WebSocket): boolean =>
(candidate.terminate as unknown as ReturnType<typeof vi.fn>).mock.calls.length > 0
// Discover the budget rather than hardcoding it: the first sweep consumes the pong above, so
// every sweep after it is an unanswered probe.
await sweep()
let toleratedMisses = 0
// Bounded so a heartbeat that never reaps fails here instead of hanging out to the test timeout.
while (!reaped(socket) && toleratedMisses < 20) {
await sweep()
toleratedMisses += 1
}
expect(reaped(socket)).toBe(true)
expect(toleratedMisses).toBeGreaterThan(1)
heartbeat.stop()
// Replay on a fresh socket: bank misses to one short of the limit, then suspend. The stalled tick
// must charge nothing — the client had no chance to answer it — but it must not forgive the misses
// already banked, or a host that stalls periodically could shelter a dead socket indefinitely.
const resumed = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now)
const resumedSocket = makeSocket()
resumed.noteAlive(resumedSocket)
resumed.start(() => [resumedSocket])
resumed.noteAlive(resumedSocket)
await sweep()
for (let miss = 0; miss < toleratedMisses - 1; miss += 1) {
await sweep()
}
expect(reaped(resumedSocket)).toBe(false)
now += 3_600_000
await vi.advanceTimersByTimeAsync(INTERVAL_MS)
// The suspend itself costs the socket nothing.
expect(reaped(resumedSocket)).toBe(false)
// But the pre-suspend evidence survives, so the very next unanswered probe completes the budget.
await sweep()
expect(reaped(resumedSocket)).toBe(true)
resumed.stop()
})
// Why: pre-fix the pardon cleared banked misses, so a host stalling once every few ticks reset the
// budget forever and a dead socket was never reaped — the connection leak the reaper exists to stop.
it('still reaps a dead socket when the host stalls repeatedly', async () => {
vi.useFakeTimers()
let now = 1_000
const socket = makeSocket()
const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now)
heartbeat.noteAlive(socket)
heartbeat.start(() => [socket])
heartbeat.noteAlive(socket)
const reaped = (): boolean =>
(socket.terminate as unknown as ReturnType<typeof vi.fn>).mock.calls.length > 0
// Stall every third tick — the worst case the old pardon could not survive.
for (let tick = 0; tick < 30 && !reaped(); tick += 1) {
now += tick % 3 === 2 ? INTERVAL_MS * 2 : INTERVAL_MS
await vi.advanceTimersByTimeAsync(INTERVAL_MS)
}
expect(reaped()).toBe(true)
heartbeat.stop()
})
})
@@ -7,12 +7,13 @@ afterEach(() => {
})
describe('RemoteRuntimeServerHeartbeat', () => {
it('still reaps a client that misses a probe while another remains alive', async () => {
it('still reaps a persistently silent client while another remains alive', async () => {
vi.useFakeTimers()
let now = 1_000
const responsiveSocket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket
const deadSocket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket
const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now)
// Limit of 2 keeps the sweep count readable; production uses the module default.
const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now, 128, 2)
heartbeat.noteAlive(responsiveSocket)
heartbeat.noteAlive(deadSocket)
// start() probes immediately: both are pinged now (probe #1) and cleared to await a pong.
@@ -20,12 +21,20 @@ describe('RemoteRuntimeServerHeartbeat', () => {
// Only the responsive socket pongs the immediate probe.
heartbeat.noteAlive(responsiveSocket)
// Miss #1: not yet evidence, so the silent socket is re-probed rather than reaped.
now += 100
await vi.advanceTimersByTimeAsync(100)
heartbeat.noteAlive(responsiveSocket)
expect(deadSocket.ping).toHaveBeenCalledTimes(2)
expect(deadSocket.terminate).not.toHaveBeenCalled()
// Miss #2 reaches the limit: consecutive silence is evidence.
now += 100
await vi.advanceTimersByTimeAsync(100)
expect(responsiveSocket.ping).toHaveBeenCalledTimes(2)
expect(responsiveSocket.ping).toHaveBeenCalledTimes(3)
expect(responsiveSocket.terminate).not.toHaveBeenCalled()
expect(deadSocket.ping).toHaveBeenCalledTimes(1)
expect(deadSocket.ping).toHaveBeenCalledTimes(2)
expect(deadSocket.terminate).toHaveBeenCalledTimes(1)
heartbeat.stop()
})
@@ -34,7 +43,8 @@ describe('RemoteRuntimeServerHeartbeat', () => {
vi.useFakeTimers()
let now = 1_000
const socket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket
const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now)
// Limit of 1 isolates the resume grant: without it the very next sweep would reap.
const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now, 128, 1)
heartbeat.noteAlive(socket)
// start() probes immediately (ping #1); the socket pongs it.
heartbeat.start(() => [socket])
@@ -1,16 +1,27 @@
import type { WebSocket } from 'ws'
// Why: one unanswered probe is UNKNOWN, not death — a cellular/Tailscale blackhole or a stalled TCP
// retransmit routinely swallows a single pong from a peer that is still there (STA-3320). Only a run of
// consecutive unanswered probes is evidence. Three matches the web client's own 45s liveness budget
// (25s idle + 20s probe grace), so both ends of a paired session give up on roughly the same evidence.
const MISSED_PROBE_LIMIT = 3
export class RemoteRuntimeServerHeartbeat {
private timer: ReturnType<typeof setInterval> | null = null
private lastTickAt: number | null = null
private readonly alive = new WeakSet<WebSocket>()
// Why: consecutive probes a socket has left unanswered. Absent = zero, so live sockets cost nothing.
private readonly missedProbes = new WeakMap<WebSocket, number>()
constructor(
private readonly intervalMs: number,
private readonly now: () => number = Date.now,
private readonly warningClientCount = 128
private readonly warningClientCount = 128,
private readonly missedProbeLimit = MISSED_PROBE_LIMIT
) {}
// Why: hot path — every inbound frame calls this. The sweep clears the miss count when it observes
// the flag, so proof of life stays a single WeakSet write.
noteAlive(socket: WebSocket): void {
this.alive.add(socket)
}
@@ -41,22 +52,32 @@ export class RemoteRuntimeServerHeartbeat {
const tickAt = this.now()
const elapsedMs = tickAt - (this.lastTickAt ?? tickAt)
this.lastTickAt = tickAt
const resumedFromPause = elapsedMs < 0 || elapsedMs > this.intervalMs * 1.5
// Why: a delayed tick cannot infer that clients missed a probe they had no chance to answer, so
// this sweep must not charge one. It must not *forgive* either: clearing banked misses lets a host
// that stalls once every few ticks protect a dead socket forever, which is the connection leak the
// reaper exists to prevent. Skipping the charge is enough — a live client clears its own count by
// answering the probe still sent below.
const stalledTick = elapsedMs < 0 || elapsedMs > this.intervalMs * 1.5
let reaped = 0
let clientCount = 0
for (const socket of clients) {
clientCount += 1
if (resumedFromPause) {
// Why: a delayed server tick cannot infer that clients missed a probe they had no chance to answer.
this.alive.add(socket)
if (this.alive.has(socket)) {
this.alive.delete(socket)
this.missedProbes.delete(socket)
} else if (!stalledTick) {
const missed = (this.missedProbes.get(socket) ?? 0) + 1
if (missed >= this.missedProbeLimit) {
this.missedProbes.delete(socket)
socket.terminate()
reaped += 1
continue
}
this.missedProbes.set(socket, missed)
}
if (!this.alive.has(socket)) {
socket.terminate()
reaped += 1
continue
}
this.alive.delete(socket)
try {
// Why: re-probe every sweep, including missed ones, so a path that just recovered can prove
// itself on the next tick instead of waiting out the rest of the budget.
socket.ping()
} catch {
// Why: a mid-teardown socket is finalized by its close/error listener.
@@ -83,7 +83,7 @@ describe('WebSocketTransport accepted socket ordering', () => {
).toEqual([0, 0, 0, 0])
})
it('reaps an unresponsive accepted socket by the first interval deadline', async () => {
it('reaps an unresponsive accepted socket once consecutive probes go unanswered', async () => {
vi.useFakeTimers()
const events: string[] = []
let socket: WebSocket
@@ -99,19 +99,22 @@ describe('WebSocketTransport accepted socket ordering', () => {
socket.once('open', () => events.push('open'))
socket.emit('open')
lifecycle.handleConnection(socket)
await vi.advanceTimersByTimeAsync(99)
// A single silent interval is not evidence: the socket is re-probed, not reaped.
await vi.advanceTimersByTimeAsync(100)
expect(socket.terminate).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(199)
expect(socket.terminate).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(socket.terminate).toHaveBeenCalledTimes(1)
expect(events).toEqual(['open', 'ping', 'close'])
expect(events).toEqual(['open', 'ping', 'ping', 'ping', 'close'])
expect(lifecycle.heartbeatConnections.size).toBe(0)
expect(lifecycle.heartbeat.timer).toBeNull()
expect(vi.getTimerCount()).toBe(0)
})
it('keeps later sockets on the shared cadence and reaps them within two intervals', async () => {
it('keeps later sockets on the shared cadence and reaps them on the shared budget', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
const firstPingTimes: number[] = []
@@ -150,13 +153,15 @@ describe('WebSocketTransport accepted socket ordering', () => {
expect(laterPingTimes).toEqual([100])
expect(laterSocket.terminate).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(99)
// Re-probed on every sweep while its misses bank, so a recovered path can answer immediately.
await vi.advanceTimersByTimeAsync(299)
expect(laterPingTimes).toEqual([100, 200, 300])
expect(laterSocket.terminate).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(laterSocket.terminate).toHaveBeenCalledTimes(1)
expect(laterReapTimes).toEqual([200])
expect(firstPingTimes).toEqual([0, 100, 200])
expect(laterReapTimes).toEqual([400])
expect(firstPingTimes).toEqual([0, 100, 200, 300, 400])
expect(
['pong', 'message', 'close', 'error'].map((event) => laterSocket.listenerCount(event))
).toEqual([0, 0, 0, 0])
@@ -0,0 +1,83 @@
// Real ws server + real ws client over loopback: a live, well-behaved peer whose pong is swallowed by
// a transient blackhole must not be terminated (STA-3320).
import { afterEach, describe, expect, it, vi } from 'vitest'
import WebSocket from 'ws'
import { WebSocketTransport } from './ws-transport'
const PROBE_INTERVAL_MS = 150
// Why: the heartbeat calls now() exactly once per sweep, so a per-call step makes elapsed exactly one
// interval every sweep — real scheduler jitter can never be mistaken for a suspended loop, and no
// assertion in this file depends on wall time.
function createStepClock(stepMs: number): () => number {
let calls = 0
return () => 1_000_000 + calls++ * stepMs
}
describe('WebSocketTransport under transient packet loss', () => {
const transports: WebSocketTransport[] = []
const sockets: WebSocket[] = []
afterEach(async () => {
for (const socket of sockets) {
socket.removeAllListeners()
socket.terminate()
}
sockets.length = 0
await Promise.all(transports.map((t) => t.stop().catch(() => {})))
transports.length = 0
})
async function startTransport(): Promise<WebSocketTransport> {
const transport = new WebSocketTransport({
host: '127.0.0.1',
port: 0,
heartbeatIntervalMs: PROBE_INTERVAL_MS,
heartbeatNow: createStepClock(PROBE_INTERVAL_MS),
// Why: this test is about liveness, not the auth handshake; keep the pre-auth reaper out of it.
preAuthTimeoutMs: 600_000
})
transports.push(transport)
await transport.start()
return transport
}
it('keeps a live client whose pong is swallowed for a single probe', async () => {
const transport = await startTransport()
// autoPong:false hands us the protocol-level pong so we can drop exactly one, the way a
// cellular/Tailscale blackhole drops exactly the frames in flight during the stall.
const client = new WebSocket(`ws://127.0.0.1:${transport.resolvedPort}`, { autoPong: false })
sockets.push(client)
let probesReceived = 0
const pongedProbes: number[] = []
const swallowedProbe = 2
let closed = false
client.on('ping', () => {
probesReceived += 1
if (probesReceived === swallowedProbe) {
return
}
pongedProbes.push(probesReceived)
client.pong()
})
client.on('close', () => {
closed = true
})
await new Promise<void>((resolve, reject) => {
client.once('open', resolve)
client.once('error', reject)
})
// Bounded by counted probe events, never by elapsed time.
await vi.waitFor(() => expect(closed || probesReceived >= swallowedProbe + 2).toBe(true), {
timeout: 15_000,
interval: 10
})
expect({ closed, probesReceived }).toEqual({ closed: false, probesReceived: 4 })
// The peer was demonstrably alive across the blackhole: it answered the probes on both sides of it.
expect(pongedProbes).toEqual([1, 3, 4])
expect(client.readyState).toBe(WebSocket.OPEN)
})
})
+1 -1
View File
@@ -21,7 +21,7 @@ type WebSocketMessageHandler = {
): void
}['bivarianceHack']
// Why: mobile clients background-suspend sockets with no TCP FIN, leaving half-opens that otherwise only the OS keepalive (~2h) reaps; a 15s ping/pong sweep bounds that to ~30s (clients auto-pong per RFC 6455).
// Why: mobile clients background-suspend sockets with no TCP FIN, leaving half-opens that otherwise only the OS keepalive (~2h) reaps; a 15s ping/pong sweep bounds that to ~60s (clients auto-pong per RFC 6455), since a reap needs consecutive unanswered probes rather than one (STA-3320).
const HEARTBEAT_INTERVAL_MS = 15_000
export type WebSocketTransportOptions = {
@@ -24,7 +24,6 @@ let issueCache: Record<string, unknown> = {}
let prCache: Record<string, unknown> = {}
let workspacePortScan: WorkspacePortScanResult | null = null
let settings: Partial<GlobalSettings> | null = null
let activityStatus = 'inactive'
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
@@ -61,7 +60,7 @@ vi.mock('@/components/ui/tooltip', () => ({
}))
vi.mock('./use-worktree-activity-status', () => ({
useWorktreeActivityStatus: () => activityStatus
useWorktreeActivityStatus: () => 'active'
}))
vi.mock('./CacheTimer', () => ({
@@ -163,7 +162,6 @@ describe('WorktreeCard linked PR display', () => {
prCache = {}
workspacePortScan = null
settings = null
activityStatus = 'inactive'
})
it('keeps linked GH PR status out of the left status slot by default', async () => {
@@ -173,8 +171,8 @@ describe('WorktreeCard linked PR display', () => {
<WorktreeCard worktree={makeWorktree({ linkedPR: 456 })} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('Inactive')
expect(markup).toContain('bg-neutral-500/40')
expect(markup).toContain('Active')
expect(markup).toContain('bg-emerald-500')
expect(markup).not.toContain('PR: Open')
expect(markup).not.toContain('Linked PR #456')
}, 20_000)
@@ -231,10 +229,11 @@ describe('WorktreeCard linked PR display', () => {
)
expect(unreadMarkup).not.toContain('aria-label="Mark as read"')
expect(unreadMarkup).toContain('Inactive · Unread')
expect(unreadMarkup).toContain('PR checks: Failed')
expect(unreadMarkup).toContain('PR checks: Failed · Unread')
expect(unreadMarkup).not.toContain('Mark read')
expect(unreadMarkup).toContain('size-[13px]')
expect(unreadMarkup).toContain('size-[13px] translate-x-px')
expect(unreadMarkup).not.toContain('lucide-bell')
expect(unreadMarkup).not.toContain('text-amber-500')
expect(unreadMarkup).toContain('data-worktree-status-lane-unread=""')
expect(unreadMarkup).toContain('data-worktree-unread-alert=""')
expect(unreadMarkup).toContain('bg-amber-500')
@@ -242,7 +241,7 @@ describe('WorktreeCard linked PR display', () => {
expect(getInlineRenameTitleTag(readMarkup)).toContain('font-normal text-foreground/80')
}, 20_000)
it('shows linked GH PR status in the title row before hosted review details are cached when new card style is on', async () => {
it('shows linked GH PR status in the left status slot before hosted review details are cached when new card style is on', async () => {
settings = { experimentalNewWorktreeCardStyle: true }
const { default: WorktreeCard } = await import('./WorktreeCard')
@@ -630,10 +629,9 @@ describe('WorktreeCard linked PR display', () => {
expect(markup).not.toContain('58941')
})
it('renders activity and linked PR status in independent sidebar lanes', async () => {
it('renders linked PR status in the left status slot instead of the right metadata list', async () => {
settings = { experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status']
activityStatus = 'active'
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: makeHostedReview({ status: 'failure' }),
@@ -647,13 +645,12 @@ describe('WorktreeCard linked PR display', () => {
)
expect(markup).toContain('PR checks: Failed')
expect(markup).toContain('Active')
expect(markup).toContain('bg-emerald-500')
expect(markup).toContain('data-worktree-card-review-status=""')
expect(markup).toContain('text-rose-500/85')
expect(markup).not.toContain('Linked PR #456')
expect(markup).not.toContain('CI checks')
})
it('uses branch PR cache for the title-row status before hosted-review metadata warms', async () => {
it('uses branch PR cache for the status slot before hosted-review metadata warms', async () => {
settings = { experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status']
prCache = {
@@ -21,7 +21,6 @@ let tabsByWorktree: Record<string, { id: string }[]> = {}
let ptyIdsByTabId: Record<string, string[]> = {}
let browserTabsByWorktree: Record<string, { id: string }[]> = {}
let settings: Partial<GlobalSettings> | null = null
let activityStatus = 'idle'
let projectGroups: unknown[] = []
let workspaceDeleteModifierPressed = false
let gitConflictOperationByWorktree: Record<string, GitConflictOperation> = {}
@@ -61,7 +60,7 @@ vi.mock('@/components/ui/tooltip', () => ({
}))
vi.mock('./use-worktree-activity-status', () => ({
useWorktreeActivityStatus: () => activityStatus
useWorktreeActivityStatus: () => 'idle'
}))
vi.mock('./CacheTimer', () => ({
@@ -145,7 +144,6 @@ describe('WorktreeCard quick actions', () => {
projectGroups = []
workspaceDeleteModifierPressed = false
gitConflictOperationByWorktree = {}
activityStatus = 'idle'
})
it('marks the unread toggle as a workspace-board-preserving action', () => {
@@ -254,19 +252,6 @@ describe('WorktreeCard quick actions', () => {
expect(markup).not.toContain('lucide-git-branch')
})
it('keeps the quiet status dot when the branch card property is off', () => {
settings = { experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status']
activityStatus = 'inactive'
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('bg-neutral-500/40')
expect(markup).not.toContain('lucide-git-branch')
})
it('does not render a pending first-agent rename title badge', () => {
const markup = renderToStaticMarkup(
<WorktreeCard
@@ -26,7 +26,6 @@ import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt'
import WorktreeCardAgents from './WorktreeCardAgents'
import { useWorktreeAgentRows } from './useWorktreeAgentRows'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
import { WorktreeCardReviewStatus } from './WorktreeCardReviewStatus'
import { cn } from '@/lib/utils'
import { WorktreeCardSshHostControl } from './WorktreeCardSshHostControl'
import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation'
@@ -1208,13 +1207,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
!!conflictOperation && conflictOperation !== 'unknown' && conflictOperation !== 'rebase'
const hasMetadataBadge = showConflictOperationBadge
const showUnreadQuickAction = !affiliateListMode && showStatus && !newCardStyle
// Why: the slot owns activity and unread; review/check state has its own title-row indicator.
// Why: the slot owns the unread/status lane; legacy keeps the bell toggle, the new card keeps the glyph passive.
const showCombinedStatusSlot = showStatus
const showReviewStatus = newCardStyle && showStatus && statusLaneReview !== null
const showTitleRowPrimary = compactCards && worktree.isMainWorktree && !isFolder
const showMetaRowDetails = !newCardStyle && !compactCards && (hasDetails || hasPorts)
const showTitleRowIndicators =
(newCardStyle || compactCards) && (hasDetails || hasPorts || showReviewStatus)
const showTitleRowIndicators = (newCardStyle || compactCards) && (hasDetails || hasPorts)
// Why: grouped views can hide the repo badge; don't reserve a blank metadata lane unless there's real content.
const hasDetailedMetaRowContent = Boolean(
(showRepoBadgeInMetaRow && repo) ||
@@ -1373,12 +1370,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
detailsAndPortsContent
)
const titleRowIndicators = showTitleRowIndicators ? (
<div className="ml-auto flex shrink-0 items-center gap-1 pr-1.5">
{showReviewStatus && statusLaneReview ? (
<WorktreeCardReviewStatus review={statusLaneReview} />
) : null}
{detailsAndPorts}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1 pr-1.5">{detailsAndPorts}</div>
) : null
const hasSecondaryCardContent =
hasMetaRow || !!remoteBranchConflict || showInlineAgentList || showLineageChildChip
@@ -1412,8 +1404,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
unreadTooltip={unreadTooltip}
onPointerDown={stopQuickActionPointerPropagation}
onToggleUnread={handleToggleUnreadQuick}
prDisplay={statusLaneReview}
newCardStyle={newCardStyle}
hasBranchIdentity={hasPathIdentityEnabled && Boolean(branchIdentityDisplay)}
hasBranchIdentity={Boolean(branchIdentityDisplay)}
/>
</div>
) : null}
@@ -1,53 +0,0 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { WorktreeCardReviewStatus } from './WorktreeCardReviewStatus'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
type ReviewOverrides = {
provider?: Exclude<WorktreeCardPrDisplay['provider'], 'unsupported'>
state?: WorktreeCardPrDisplay['state']
status?: WorktreeCardPrDisplay['status']
}
function renderReview(overrides: ReviewOverrides = {}): string {
const review: WorktreeCardPrDisplay = {
provider: 'github',
number: 123,
title: 'Review me',
state: 'open',
...overrides
}
return renderToStaticMarkup(<WorktreeCardReviewStatus review={review} />)
}
describe('WorktreeCardReviewStatus', () => {
it.each([
[{ state: 'merged' }, 'PR: Merged'],
[{ state: 'closed' }, 'PR: Closed'],
[{ state: 'draft' }, 'PR: Draft'],
[{ status: 'failure' }, 'PR checks: Failed'],
[{ status: 'pending' }, 'PR checks: Pending'],
[{ status: 'success' }, 'PR checks: Passing'],
[{}, 'PR: Open']
] as const)('describes review state %o as %s', (overrides, label) => {
expect(renderReview(overrides)).toContain(label)
})
it('uses the compact generic review glyph in its own lane', () => {
const markup = renderReview({ provider: 'gitlab', status: 'pending' })
expect(markup).toContain('data-worktree-card-review-status=""')
expect(markup).toContain('MR checks: Pending')
expect(markup).toContain('viewBox="0 0 16 16"')
expect(markup).toContain('size-[13px]')
expect(markup).toContain('text-amber-500/85')
expect(markup).not.toContain('lucide-git-merge')
})
})
@@ -1,52 +0,0 @@
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import { getReviewLabel, ReviewIcon } from './worktree-review-helpers'
type WorktreeCardReviewStatusProps = {
review: WorktreeCardPrDisplay
}
function getReviewStatusTooltip(review: WorktreeCardPrDisplay): string {
const label = getReviewLabel(review)
if (review.state === 'merged') {
return `${label}: Merged`
}
if (review.state === 'closed') {
return `${label}: Closed`
}
if (review.state === 'draft') {
return `${label}: Draft`
}
if (review.status === 'failure') {
return `${label} checks: Failed`
}
if (review.status === 'pending') {
return `${label} checks: Pending`
}
if (review.status === 'success') {
return `${label} checks: Passing`
}
return `${label}: Open`
}
export function WorktreeCardReviewStatus({
review
}: WorktreeCardReviewStatusProps): React.JSX.Element {
const tooltip = getReviewStatusTooltip(review)
return (
<Tooltip>
<TooltipTrigger asChild>
<span
data-worktree-card-review-status=""
className="inline-flex size-5 shrink-0 items-center justify-center p-0.5"
>
<ReviewIcon review={review} className="size-[13px]" variant="generic" />
<span className="sr-only">{tooltip}</span>
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<span>{tooltip}</span>
</TooltipContent>
</Tooltip>
)
}
@@ -2,6 +2,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
const mocks = vi.hoisted(() => ({
status: 'active'
@@ -22,6 +23,21 @@ describe('WorktreeCardStatusSlot', () => {
mocks.status = 'active'
})
const review: WorktreeCardPrDisplay = {
provider: 'github',
number: 123,
title: 'Review me',
state: 'open',
status: 'failure'
}
const gitlabReview: WorktreeCardPrDisplay = {
provider: 'gitlab',
number: 456,
title: 'Review me',
state: 'open',
status: 'pending'
}
it('lets the unread bell replace the visual status dot by default', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
@@ -157,7 +173,7 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).toContain('bg-emerald-500')
})
it('keeps the emerald activity dot ahead of branch identity when new card style is on', () => {
it('keeps the quiet active dot ahead of PR status by default', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
@@ -167,17 +183,60 @@ describe('WorktreeCardStatusSlot', () => {
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
hasBranchIdentity
prDisplay={review}
/>
)
expect(markup).toContain('Active')
expect(markup).toContain('bg-emerald-500')
expect(markup).not.toContain('lucide-git-branch')
expect(markup).not.toContain('PR checks: Failed')
})
it('shows done activity in new card style', () => {
it('uses PR status instead of the quiet active dot when new card style is on', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
newCardStyle
/>
)
expect(markup).toContain('PR checks: Failed')
expect(markup).toContain('inline-flex size-5 items-center justify-center')
expect(markup).toContain('size-[13px] translate-x-px')
expect(markup).toContain('text-rose-500/85')
expect(markup).not.toContain('bg-emerald-500')
})
it('uses the unified compact review glyph for GitLab MR status', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={gitlabReview}
newCardStyle
/>
)
expect(markup).toContain('MR checks: Pending')
expect(markup).toContain('viewBox="0 0 16 16"')
expect(markup).toContain('size-[13px] translate-x-px')
expect(markup).toContain('text-amber-500/85')
expect(markup).not.toContain('lucide-git-merge')
})
it('uses PR status instead of the quiet done dot when new card style is on', () => {
mocks.status = 'done'
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
@@ -188,16 +247,37 @@ describe('WorktreeCardStatusSlot', () => {
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
newCardStyle
/>
)
expect(markup).toContain('Done')
expect(markup).toContain('bg-emerald-500')
expect(markup).toContain('PR checks: Failed')
expect(markup).not.toContain('bg-emerald-500')
})
it('uses a branch icon with branch-only tooltip copy on quiet rows', () => {
it('uses PR status instead of the inactive dot when new card style is on', () => {
mocks.status = 'inactive'
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
newCardStyle
/>
)
expect(markup).toContain('PR checks: Failed')
expect(markup).toContain('text-rose-500/85')
expect(markup).not.toContain('bg-neutral-500/40')
})
it('uses a branch icon with branch-only tooltip copy by default', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
@@ -221,7 +301,6 @@ describe('WorktreeCardStatusSlot', () => {
})
it('uses context-aware branch or folder path tooltip copy', () => {
mocks.status = 'inactive'
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
@@ -261,7 +340,7 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).not.toContain('lucide-git-branch')
})
it('shows working activity in new card style', () => {
it('keeps working activity ahead of PR status in new card style', () => {
mocks.status = 'working'
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
@@ -272,6 +351,7 @@ describe('WorktreeCardStatusSlot', () => {
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
newCardStyle
/>
)
@@ -279,9 +359,10 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).toContain('Working')
expect(markup).toContain('inline-flex size-5 items-center justify-center')
expect(markup).toContain('border-yellow-500')
expect(markup).not.toContain('PR checks: Failed')
})
it('shows permission activity in new card style', () => {
it('keeps permission activity ahead of PR status in new card style', () => {
mocks.status = 'permission'
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
@@ -292,6 +373,7 @@ describe('WorktreeCardStatusSlot', () => {
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
newCardStyle
/>
)
@@ -299,10 +381,61 @@ describe('WorktreeCardStatusSlot', () => {
expect(markup).toContain('Needs permission')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-amber-500')
expect(markup).not.toContain('PR checks: Failed')
})
it('keeps unread ahead of PR status by default', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction
isUnread
unreadTooltip="Mark as read"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
/>
)
expect(markup).toContain('aria-label="Mark as read"')
expect(markup).toContain('Mark as read')
expect(markup).not.toContain('Active · Mark as read')
expect(markup).not.toContain('PR checks: Failed')
expect(markup).not.toContain('bg-emerald-500')
expect(markup).toContain('text-amber-500')
})
it('overlays an unread badge on PR status when new card style is on', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction
isUnread
unreadTooltip="Mark as read"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
newCardStyle
/>
)
expect(markup).not.toContain('aria-label="Mark as read"')
expect(markup).not.toContain('Mark as read')
expect(markup).toContain('PR checks: Failed · Unread')
expect(markup).toContain('data-worktree-status-lane-unread=""')
expect(markup).toContain('data-worktree-unread-alert=""')
expect(markup).not.toContain('group/unread')
expect(markup).not.toContain('cursor-pointer')
expect(markup).toContain('text-rose-500/85')
expect(markup).toContain('bg-amber-500')
expect(markup).not.toContain('lucide-bell')
expect(markup).not.toContain('text-amber-500')
expect(markup).not.toContain('bg-emerald-500')
})
it('overlays an unread badge on the branch icon in new card style', () => {
mocks.status = 'inactive'
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
@@ -3,10 +3,12 @@ import { Bell, GitBranch } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { getWorktreeStatusLabel } from '@/lib/worktree-status'
import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status'
import { FilledBellIcon } from './WorktreeCardHelpers'
import StatusIndicator from './StatusIndicator'
import { useWorktreeActivityStatus } from './use-worktree-activity-status'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import { getReviewLabel, ReviewIcon } from './worktree-review-helpers'
type WorktreeCardStatusSlotProps = {
worktreeId: string
@@ -16,20 +18,23 @@ type WorktreeCardStatusSlotProps = {
unreadTooltip: string
onToggleUnread: React.MouseEventHandler<HTMLButtonElement>
onPointerDown: React.PointerEventHandler<HTMLButtonElement>
prDisplay?: WorktreeCardPrDisplay | null
newCardStyle?: boolean
hasBranchIdentity?: boolean
branchIdentityLabel?: string
className?: string
}
// Why: the passive label names the identity cue without claiming review state.
const QUIET_REVIEW_REPLACEABLE_STATUSES = new Set<WorktreeStatus>(['active', 'done', 'inactive'])
// Why: a missing review display can also mean provider state is unavailable,
// so the passive label names the identity cue without claiming no review exists.
function getDefaultBranchIdentityLabel(): string {
return translate('auto.components.sidebar.WorktreeCardStatusSlot.branchIdentity', 'Branch')
}
// Why: branch-style SVGs are optically left-heavy; this keeps them aligned with
// the centered activity dots in the shared status column.
const compactBranchStatusIconClassName = 'size-[13px] translate-x-px'
const branchStatusIconClassName = `${compactBranchStatusIconClassName} text-muted-foreground/70`
const compactReviewAndBranchStatusIconClassName = 'size-[13px] translate-x-px'
const branchStatusIconClassName = `${compactReviewAndBranchStatusIconClassName} text-muted-foreground/70`
// Why: a left-edge badge overlays unread on the status glyph without widening
// the lane or indenting the title; ring-sidebar cuts the dot out from busy icons.
const newCardUnreadAlertClassName =
@@ -58,6 +63,29 @@ function overlayNewCardUnreadStatus(
)
}
function getReviewStatusTooltip(review: WorktreeCardPrDisplay): string {
const label = getReviewLabel(review)
if (review.state === 'merged') {
return `${label}: Merged`
}
if (review.state === 'closed') {
return `${label}: Closed`
}
if (review.state === 'draft') {
return `${label}: Draft`
}
if (review.status === 'failure') {
return `${label} checks: Failed`
}
if (review.status === 'pending') {
return `${label} checks: Pending`
}
if (review.status === 'success') {
return `${label} checks: Passing`
}
return `${label}: Open`
}
export function WorktreeCardStatusSlot({
worktreeId,
showStatus,
@@ -66,6 +94,7 @@ export function WorktreeCardStatusSlot({
unreadTooltip,
onToggleUnread,
onPointerDown,
prDisplay = null,
newCardStyle = false,
hasBranchIdentity = false,
branchIdentityLabel,
@@ -73,43 +102,73 @@ export function WorktreeCardStatusSlot({
}: WorktreeCardStatusSlotProps): React.JSX.Element | null {
const status = useWorktreeActivityStatus(worktreeId)
const statusLabel = getWorktreeStatusLabel(status) || status
const canShowReviewStatus =
newCardStyle &&
showStatus &&
prDisplay !== null &&
QUIET_REVIEW_REPLACEABLE_STATUSES.has(status)
const canShowBranchStatus =
newCardStyle && showStatus && hasBranchIdentity && status === 'inactive'
const passiveStatusLabel = canShowBranchStatus
? (branchIdentityLabel ?? getDefaultBranchIdentityLabel())
: statusLabel
newCardStyle &&
showStatus &&
hasBranchIdentity &&
prDisplay === null &&
QUIET_REVIEW_REPLACEABLE_STATUSES.has(status)
const passiveStatusLabel =
canShowReviewStatus && prDisplay
? getReviewStatusTooltip(prDisplay)
: canShowBranchStatus
? (branchIdentityLabel ?? getDefaultBranchIdentityLabel())
: statusLabel
const passiveStatusTooltip =
newCardStyle && isUnread ? `${passiveStatusLabel} · Unread` : passiveStatusLabel
// Why: working and permission already own the new-card status lane, but
// unread state should still surface in tooltip/sr-only copy and reappear afterward.
const showNewCardUnreadAlert =
newCardStyle && isUnread && showStatus && status !== 'working' && status !== 'permission'
const reviewStatusIconClassName = compactReviewAndBranchStatusIconClassName
const branchStatusIcon = <GitBranch className={branchStatusIconClassName} aria-hidden="true" />
const passiveStatus = canShowBranchStatus ? (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn('inline-flex size-5 items-center justify-center p-0.5', className)}>
{branchStatusIcon}
<span className="sr-only">{passiveStatusTooltip}</span>
const passiveStatus =
canShowReviewStatus && prDisplay ? (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn('inline-flex size-5 items-center justify-center p-0.5', className)}>
<ReviewIcon
review={prDisplay}
className={reviewStatusIconClassName}
variant="generic"
/>
<span className="sr-only">{passiveStatusTooltip}</span>
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<span>{passiveStatusTooltip}</span>
</TooltipContent>
</Tooltip>
) : canShowBranchStatus ? (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn('inline-flex size-5 items-center justify-center p-0.5', className)}>
{branchStatusIcon}
<span className="sr-only">{passiveStatusTooltip}</span>
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<span>{passiveStatusTooltip}</span>
</TooltipContent>
</Tooltip>
) : newCardStyle && showStatus ? (
<>
<span className={cn('inline-flex size-5 items-center justify-center', className)}>
<StatusIndicator status={status} aria-hidden="true" />
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<span>{passiveStatusTooltip}</span>
</TooltipContent>
</Tooltip>
) : newCardStyle && showStatus ? (
<>
<span className={cn('inline-flex size-5 items-center justify-center', className)}>
<StatusIndicator status={status} aria-hidden="true" />
</span>
<span className="sr-only">{passiveStatusTooltip}</span>
</>
) : (
<>
<StatusIndicator status={status} aria-hidden="true" className={className} />
<span className="sr-only">{statusLabel}</span>
</>
)
<span className="sr-only">{passiveStatusTooltip}</span>
</>
) : (
<>
<StatusIndicator status={status} aria-hidden="true" className={className} />
<span className="sr-only">{statusLabel}</span>
</>
)
const unreadActionEnabled = showUnreadAction && !newCardStyle
@@ -144,7 +203,15 @@ export function WorktreeCardStatusSlot({
aria-label={actionLabel}
>
{newCardStyle ? (
showStatus && canShowBranchStatus ? (
showStatus && canShowReviewStatus && prDisplay ? (
<span className="inline-flex size-5 items-center justify-center p-0.5">
<ReviewIcon
review={prDisplay}
className={reviewStatusIconClassName}
variant="generic"
/>
</span>
) : showStatus && canShowBranchStatus ? (
<span className="inline-flex size-5 items-center justify-center p-0.5">
{branchStatusIcon}
</span>
@@ -210,6 +210,8 @@ describe('WebRuntimeClient', () => {
}
timerWindow.setTimeout = setTimeout
timerWindow.clearTimeout = clearTimeout
// Pin the one-sided reconnect jitter to zero so the redial lands on its exact backoff step.
const random = vi.spyOn(Math, 'random').mockReturnValue(0)
const client = new WebRuntimeClient({
v: 2,
endpoint: 'ws://127.0.0.1:6768',
@@ -235,6 +237,7 @@ describe('WebRuntimeClient', () => {
expect(replacementSocket.send).toHaveBeenCalledTimes(1)
} finally {
client.close()
random.mockRestore()
vi.useRealTimers()
}
})
+3 -1
View File
@@ -16,6 +16,7 @@ import {
} from './web-e2ee'
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import { createWebRuntimeUnauthorizedError } from './web-runtime-client-error'
import { withReconnectJitter } from '../../../shared/reconnect-jitter'
type WebRuntimeConnectionState =
| 'disconnected'
@@ -641,8 +642,9 @@ export class WebRuntimeClient {
if (this.reconnectTimer || this.intentionallyClosed) {
return
}
const delay =
const delay = withReconnectJitter(
RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)]
)
this.reconnectAttempt += 1
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { withReconnectJitter } from './reconnect-jitter'
describe('withReconnectJitter', () => {
it('never returns less than the backoff floor', () => {
expect(withReconnectJitter(500, () => 0)).toBe(500)
})
it('spreads a fleet that was dropped by one shared blip', () => {
const fleet = Array.from({ length: 32 }, (_, index) =>
withReconnectJitter(500, () => index / 32)
)
expect(new Set(fleet).size).toBeGreaterThan(1)
expect(Math.min(...fleet)).toBeGreaterThanOrEqual(500)
expect(Math.max(...fleet)).toBeLessThanOrEqual(600)
})
})
+7
View File
@@ -0,0 +1,7 @@
// Why: a blip on a shared path (Tailscale, cellular, host restart) drops every socket on that path in
// the same instant. A jitterless backoff ladder then re-dials all of them at the same millisecond,
// which is exactly the thundering herd the host is least able to absorb while it is still recovering.
// One-sided so a delay is never shortened below its backoff floor.
export function withReconnectJitter(delayMs: number, random: () => number = Math.random): number {
return delayMs + Math.floor(delayMs * 0.2 * random())
}
@@ -10,6 +10,7 @@ import type {
SharedControlReadyWaiter
} from './remote-runtime-shared-control-types'
import { getSubscriptionId, isEndResult } from './remote-runtime-shared-control-protocol'
import { withReconnectJitter } from './reconnect-jitter'
import { tagRuntimeSubscriptionReplayResponse } from './runtime-subscription-replay'
export function buildSharedControlDiagnostics(args: {
@@ -208,9 +209,3 @@ export function scheduleSharedControlReconnect(args: {
return { timer, reconnectAttempt: args.reconnectAttempt + 1 }
}
function withReconnectJitter(delayMs: number): number {
// Why: when a remote host restarts, all passive subscriptions reconnect
// together. A small one-sided jitter avoids synchronized retry spikes.
const jitterMs = Math.floor(delayMs * 0.2 * Math.random())
return delayMs + jitterMs
}
@@ -1,84 +0,0 @@
// Regression coverage for active status taking precedence over passive identity (#8813).
import type { Locator, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import {
waitForSessionReady,
waitForActiveWorktree,
getAllWorktreeIds,
ensureTerminalVisible
} from './helpers/store'
import { worktreeRow, worktreeRowSurface } from './worktree-row-locators'
function statusLane(page: Page, worktreeId: string): Locator {
return worktreeRow(page, worktreeId).locator('[data-worktree-card-status-slot]').first()
}
function statusDot(page: Page, worktreeId: string): Locator {
return statusLane(page, worktreeId).locator('span.bg-emerald-500').first()
}
function branchIdentityGlyph(page: Page, worktreeId: string): Locator {
return statusLane(page, worktreeId).locator('svg.lucide-git-branch')
}
// PTY liveness, not the tab row, drives the activity heuristic.
async function waitForLivePty(page: Page, worktreeId: string): Promise<void> {
await expect
.poll(
async () =>
page.evaluate((id) => {
const state = window.__store!.getState()
return (state.tabsByWorktree[id] ?? []).some(
(tab) => (state.ptyIdsByTabId[tab.id] ?? []).length > 0
)
}, worktreeId),
{ timeout: 30_000, message: `No live PTY attached for worktree ${worktreeId}` }
)
.toBe(true)
}
test.describe('Worktree card status indicator', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
test('paints the emerald Active dot instead of the grey branch glyph once a workspace goes live', async ({
orcaPage
}) => {
const liveWorktreeId = await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForLivePty(orcaPage, liveWorktreeId)
// The regression requires both the new card style and branch identity.
await orcaPage.evaluate(async () => {
const state = window.__store!.getState()
await state.updateSettings({ experimentalNewWorktreeCardStyle: true })
state.setWorktreeCardProperties(['status', 'unread', 'branch'])
})
const quietWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== liveWorktreeId)
if (!quietWorktreeId) {
throw new Error('Seeded repo did not expose a second worktree to keep quiet')
}
await expect(branchIdentityGlyph(orcaPage, quietWorktreeId)).toBeVisible()
await expect(statusLane(orcaPage, quietWorktreeId)).toHaveText('Branch')
await expect(statusDot(orcaPage, liveWorktreeId)).toBeVisible()
await expect(statusLane(orcaPage, liveWorktreeId)).toHaveText('Active')
await expect(branchIdentityGlyph(orcaPage, liveWorktreeId)).toHaveCount(0)
await worktreeRowSurface(orcaPage, quietWorktreeId).click()
await expect
.poll(async () => orcaPage.evaluate(() => window.__store!.getState().activeWorktreeId))
.toBe(quietWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForLivePty(orcaPage, quietWorktreeId)
await expect(statusDot(orcaPage, quietWorktreeId)).toBeVisible()
await expect(statusLane(orcaPage, quietWorktreeId)).toHaveText('Active')
await expect(branchIdentityGlyph(orcaPage, quietWorktreeId)).toHaveCount(0)
})
})