mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/status-check
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user