From 17df1b14409a82ec83decfc6bbde23fb9efd66bd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:32:39 -0700 Subject: [PATCH 1/5] Show 'Pi isn't signed in' before the first send A Pi chat whose agent started with no model to use now says so in the existing availability notice, and the next send replaces that agent so a sign-in made since takes effect. --- ...structured-agent-session-adapter-router.ts | 3 + .../structured-agent-session-adapter.ts | 8 +- .../structured-agent-session-host-delivery.ts | 1 + .../structured-agent-session-rest-test-rig.ts | 2 + ...red-agent-session-signed-out-child.test.ts | 20 +++++ ...ructured-agent-session-signed-out-child.ts | 17 ++++- src/main/pi/rpc-session-adapter.test.ts | 20 +++++ src/main/pi/rpc-session-adapter.ts | 9 +++ ...uctured-agent-session-options-read.test.ts | 75 ++++++++++++++++++- .../structured-agent-session-options-read.ts | 44 ++++++----- 10 files changed, 175 insertions(+), 24 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index ee8e13847502..25e700960780 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -128,6 +128,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi this.liveOwnerOrNull(sessionId)?.holdsLiveProviderProcess?.(sessionId, acquisitionGeneration) ?? false + startUnavailable: NonNullable = (sessionId) => + this.liveOwnerOrNull(sessionId)?.startUnavailable?.(sessionId) + stopEndsSession = (sessionId: string): boolean => this.liveOwnerOrNull(sessionId)?.stopEndsSession?.(sessionId) ?? false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index d753547d57ee..9aa03a27ab07 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -1,4 +1,7 @@ -import type { AgentSessionAccountKind } from '../../../shared/agent-session-availability' +import type { + AgentSessionAccountKind, + AgentSessionUnavailable +} from '../../../shared/agent-session-availability' import type { AgentSessionRewindReason, AgentSessionRewindSupport @@ -381,6 +384,9 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** The adapter's own child for this exact acquisition has a pid and its root exit has not been * seen: first-hand proof of life for lease renewal. Absent or false falls back to a PID probe. */ holdsLiveProviderProcess?(sessionId: string, acquisitionGeneration: string): boolean + /** Why the session's running child can take no turn, as its own start found; undefined when + * unknown or no child runs. Lives and dies with that child. */ + startUnavailable?(sessionId: string): AgentSessionUnavailable | undefined /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 2df29121596d..9ff8543d514e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -85,6 +85,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { }, providerHoldsDispatch: () => deps.adapter.holdsDispatch?.(sessionId) === true }, + startUnavailable: () => deps.adapter.startUnavailable?.(sessionId), stopAgent: input.stopSignedOutAgent, logger: deps.logger }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index c16fa93048ba..639cdc9b3ae7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -49,6 +49,7 @@ export type RestTestAdapter = { NonNullable > holdsDispatch: Mock> + startUnavailable: Mock> readOptions: Mock> } @@ -159,6 +160,7 @@ export async function createRestTestRig( dispatch: vi.fn(async () => acceptedDispatch()), acknowledgeSessionRelease: vi.fn(), holdsDispatch: vi.fn(() => false), + startUnavailable: vi.fn(() => undefined), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) } const hostFor = (overrides: Partial) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts index 30eec72920a6..ae09e13d8782 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts @@ -23,6 +23,7 @@ import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability' import type { JournalItemLinkageVisitor } from '../agent-session-journal/journal-store-contracts' import type { StructuredAgentSessionChildWorkReads } from './structured-agent-session-idle-sweep' @@ -63,6 +64,17 @@ describe('a send after the agent said it is not signed in', () => { expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) }) + // A Pi that started with no model keeps a placeholder: a sign-in made since reaches a new Pi only. + it('goes to a new agent when the running one started signed out', async () => { + await foundRestTestChat(rig) + rig.adapter.startUnavailable.mockReturnValueOnce({ reason: 'notSignedIn' }) + rig.adapter.closeSession.mockClear() + expect((await rig.host.send(CALLER, restTestSend('signed in since', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) + }) + it('keeps the agent after any other rejection', async () => { await foundRestTestChat(rig) rig.adapter.dispatch.mockImplementationOnce(async () => ({ @@ -163,6 +175,14 @@ describe('structuredAgentSessionChildReportedSignedOut', () => { expect(reported({ items, child: childAt({ phase: 'starting' }) })).toBe(false) expect(reported({ items, child: null })).toBe(false) }) + + it("reads the running child's own start, and only a signed-out one", () => { + const fromStart = (startUnavailable: AgentSessionUnavailable, child = childAt({})) => + structuredAgentSessionChildReportedSignedOut(conversation({ child }), startUnavailable) + expect(fromStart({ reason: 'notSignedIn' })).toBe(true) + expect(fromStart({ reason: 'cliMissing' })).toBe(false) + expect(fromStart({ reason: 'notSignedIn' }, childAt({ phase: 'starting' }))).toBe(false) + }) }) describe('retireSignedOutStructuredAgentSessionChild', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts index 92f64a8f3a46..40ea59a69eb8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts @@ -1,6 +1,7 @@ // A child that reported its agent is not signed in is replaced before the next send. Some agents // read their saved login only when they start, so a sign-in made since reaches a new child only. +import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability' import { readWholeAgentSessionFailureFact } from '../../../shared/agent-session-failure' import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' @@ -26,13 +27,20 @@ type SignedOutReading = Pick & { const signedOut = (failure: unknown): boolean => readWholeAgentSessionFailureFact(failure)?.kind === 'notSignedIn' -/** The running child itself said it is not signed in: a send it rejected, or a row it wrote. Each - * child holds its own fence, so matching it scopes both to this child; a new one has nothing. */ -export function structuredAgentSessionChildReportedSignedOut(session: SignedOutReading): boolean { +/** The running child itself said it is not signed in: its start, a send it rejected, or a row it + * wrote. Each child holds its own fence, so matching it scopes both to this child; a new one has + * nothing. `startUnavailable` is the adapter's read of that child's start. */ +export function structuredAgentSessionChildReportedSignedOut( + session: SignedOutReading, + startUnavailable?: AgentSessionUnavailable +): boolean { const { child, journal } = session if (!child || child.phase === 'starting' || child.close) { return false } + if (startUnavailable?.reason === 'notSignedIn') { + return true + } if ( journal .submissions() @@ -78,13 +86,14 @@ export async function retireSignedOutStructuredAgentSessionChild( session: SignedOutReading | undefined, deps: { work: StructuredAgentSessionChildWorkReads + startUnavailable?: () => AgentSessionUnavailable | undefined stopAgent: (sessionId: string) => Promise logger: StructuredAgentSessionLogger } ): Promise { if ( !session || - !structuredAgentSessionChildReportedSignedOut(session) || + !structuredAgentSessionChildReportedSignedOut(session, deps.startUnavailable?.()) || structuredAgentSessionChildHasOpenWork(session.journal, deps.work) || owesWakeUp(session.journal, deps.work.childWork()) ) { diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 023e972a4481..2767ab8ed039 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -560,3 +560,23 @@ describe('Pi RPC session ownership and delivery', () => { expect(await other.adapter.closeSession(sessionId)).toBe(false) }) }) + +describe('Pi RPC start without a model', () => { + it('reports a start that listed no model as signed out, for that child only', async () => { + const h = await setup() + expect(h.adapter.startUnavailable(sessionId)).toBeUndefined() + await h.adapter.closeSession(sessionId) + h.openConnection.mockImplementationOnce((_launch, handlers) => { + const connection = new FakeConnection(handlers) + // What a signed-out Pi lists (captured from Pi 1.0.4 in __fixtures__/signed-out.jsonl). + connection.requestOverride = (command) => + command === 'get_available_models' ? Promise.resolve({ models: [] }) : undefined + h.connections.push(connection) + return connection + }) + await h.adapter.acquire({ ...h.input, fence: 8 }) + expect(h.adapter.startUnavailable(sessionId)).toEqual({ reason: 'notSignedIn' }) + await h.adapter.closeSession(sessionId) + expect(h.adapter.startUnavailable(sessionId)).toBeUndefined() + }) +}) diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index c746d15b2ebd..ab714c9d603e 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import type { AgentSessionUnavailable } from '../../shared/agent-session-availability' import { agentSessionFailureFact } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import { @@ -233,6 +234,14 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { holdsDispatch(id: string): boolean { return this.sessions.get(id)?.turns.holdsDispatch ?? false } + /** Started with no model listed: Pi keeps a placeholder, so every prompt this child takes fails + * as not signed in, even after a sign-in, which only a new Pi reads. */ + startUnavailable(id: string): AgentSessionUnavailable | undefined { + const session = this.sessions.get(id) + return session && !session.connection.closed && session.options?.models.length === 0 + ? { reason: 'notSignedIn' } + : undefined + } async closeSession(id: string, requested = true): Promise { const session = this.sessions.get(id) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts index d3bb69a6e004..bfd15a7c7327 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts @@ -2,9 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { AgentSessionUnavailable } from '../../../../shared/agent-session-availability' import type { AgentSessionRecord } from '../../../../shared/agent-session-record' +import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { call, clearStructuredHostStub, @@ -26,7 +32,7 @@ describe('agentSession.modelCatalog', () => { record = null setStructuredAgentSessionHost( Object.assign(hostStub(), { - deps: { modelCatalog: { read }, store: { getRecord: () => record } } + deps: { modelCatalog: { read }, store: { getRecord: () => record }, adapter: {} } }) ) }) @@ -108,7 +114,7 @@ describe('agentSession.modelCatalog before anything built the host', () => { const installHost = vi.fn(async () => { setStructuredAgentSessionHost( Object.assign(hostStub(), { - deps: { modelCatalog: { read }, store: { getRecord: () => null } } + deps: { modelCatalog: { read }, store: { getRecord: () => null }, adapter: {} } }) ) }) @@ -155,3 +161,68 @@ describe('agentSession.modelCatalog before anything built the host', () => { expect(reply).toMatchObject({ ok: false }) }) }) + +const PI_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: [ + ...STRUCTURED_CLIENT.clientCapabilities, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY + ] +} + +describe("agentSession.modelCatalog with what the running agent's start said", () => { + const read = vi.fn(async (): Promise => ({ origin: 'unknown' })) + const startUnavailable = vi.fn((_sessionId: string): AgentSessionUnavailable | undefined => ({ + reason: 'notSignedIn' + })) + const record: AgentSessionRecord = { ...agentSessionRecordFixture(), provider: 'pi' } + const readFor = (agent: string) => + call('agentSession.modelCatalog', { agent, sessionId: record.sessionId }, PI_CLIENT) + + beforeEach(() => { + read.mockReset() + read.mockResolvedValue({ origin: 'unknown' }) + startUnavailable.mockClear() + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + deps: { + modelCatalog: { read }, + store: { getRecord: () => record }, + adapter: { startUnavailable } + } + }) + ) + }) + + it("answers a Pi chat's catalog with its signed-out start", async () => { + const reply = await readFor('pi') + expect(startUnavailable).toHaveBeenCalledWith(record.sessionId) + expect(reply).toMatchObject({ + ok: true, + result: { origin: 'unknown', unavailable: { reason: 'notSignedIn' } } + }) + }) + + it('keeps the reason the catalog itself found', async () => { + read.mockResolvedValue({ origin: 'unknown', unavailable: { reason: 'cliMissing' } }) + expect(await readFor('pi')).toMatchObject({ + ok: true, + result: { unavailable: { reason: 'cliMissing' } } + }) + }) + + it("never lends one agent's start to another agent's read", async () => { + const reply = await readFor('codex') + expect(startUnavailable).not.toHaveBeenCalled() + expect(reply).toMatchObject({ ok: true, result: { origin: 'unknown' } }) + expect(reply).not.toMatchObject({ result: { unavailable: expect.anything() } }) + }) + + it('stays quiet while the start said nothing', async () => { + startUnavailable.mockReturnValueOnce(undefined) + const reply = await readFor('pi') + expect(reply).toMatchObject({ ok: true }) + expect(reply).not.toMatchObject({ result: { unavailable: expect.anything() } }) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 6259125a151a..4f650e9ed9cd 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -3,8 +3,8 @@ // // `agentSession.options` answers at rest from the record and the catalog, and // asks the live provider only when one runs. `agentSession.modelCatalog` deliberately does -// neither — answering from the host store is what lets a picker render while -// an attach is still running. It is additive: an older host answers +// neither — answering from the host store (and what a running child's start already said) is +// what lets a picker render while an attach is still running. It is additive: an older host answers // `method_not_found` (or `forbidden` through the mobile allowlist gate), and // the client keeps its static seed. @@ -20,6 +20,7 @@ import { OptionsParams } from './structured-agent-session-schemas' import { agentSessionPinnedLaunchDirectory } from '../../agent-session-record-launch-directory' +import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ defineMethod({ @@ -55,24 +56,33 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ params.sessionId === undefined ? requireStructuredHost(ctx) : await requireInstalledStructuredHost(ctx, params.sessionId) - const catalog = host.deps.modelCatalog - if (!catalog) { - return { origin: 'unknown' as const } - } - // A floating chat runs in the folder it was created in, not the floating setting's current one. const record = params.sessionId === undefined ? null : host.deps.store.getRecord(params.sessionId) - const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined - if (launchDirectory) { - return catalog.read({ ...params, workspacePath: launchDirectory }) - } - if (worktree === undefined) { - return catalog.read(params) + const read = async (): Promise => { + const catalog = host.deps.modelCatalog + if (!catalog) { + return { origin: 'unknown' } + } + // A floating chat runs in the folder it was created in, not the floating setting's current one. + const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined + if (launchDirectory) { + return catalog.read({ ...params, workspacePath: launchDirectory }) + } + if (worktree === undefined) { + return catalog.read(params) + } + const workspacePath = await ctx.runtime + .resolveStructuredAgentSessionLocalWorkspacePath(worktree) + .catch(() => null) + return catalog.read({ ...params, workspacePath }) } - const workspacePath = await ctx.runtime - .resolveStructuredAgentSessionLocalWorkspacePath(worktree) - .catch(() => null) - return catalog.read({ ...params, workspacePath }) + const answer = await read() + // The chat's running agent can say what no listing does: a Pi that started with no model. + const started = + record?.provider === params.agent + ? host.deps.adapter.startUnavailable?.(record.sessionId) + : undefined + return started && !answer.unavailable ? { ...answer, unavailable: started } : answer } }) ] From 37adb67c7f7d0d448a3dfac063ffb54138321daf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:47:11 -0700 Subject: [PATCH 2/5] Re-read the sign-in notice when the chat's agent stops; ignore an exited Pi A Pi's start verdict ends with that Pi: the notice now re-reads when the host starts or stops the chat's agent, and a Pi whose process already exited reports nothing. --- src/main/pi/rpc-session-adapter.test.ts | 6 ++++++ src/main/pi/rpc-session-adapter.ts | 5 ++++- .../NativeChatStructuredSession.tsx | 1 + .../use-host-model-catalog-upgrade.ts | 18 +++++++++++++--- ...model-catalog-upgrade.unavailable.test.tsx | 21 +++++++++++++++++-- .../use-structured-agent-session-options.ts | 2 ++ .../use-structured-agent-session.ts | 3 +++ 7 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 2767ab8ed039..0d991d08cbe7 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -576,6 +576,12 @@ describe('Pi RPC start without a model', () => { }) await h.adapter.acquire({ ...h.input, fence: 8 }) expect(h.adapter.startUnavailable(sessionId)).toEqual({ reason: 'notSignedIn' }) + // Its root gone while its output still drains: that child says nothing more. + const child = h.connections.at(-1)! + child.rootVerdict = 'exited' + expect(child.closed).toBe(false) + expect(h.adapter.startUnavailable(sessionId)).toBeUndefined() + child.rootVerdict = 'live' await h.adapter.closeSession(sessionId) expect(h.adapter.startUnavailable(sessionId)).toBeUndefined() }) diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index ab714c9d603e..8f4feb7e59ee 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -238,7 +238,10 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { * as not signed in, even after a sign-in, which only a new Pi reads. */ startUnavailable(id: string): AgentSessionUnavailable | undefined { const session = this.sessions.get(id) - return session && !session.connection.closed && session.options?.models.length === 0 + return session && + !session.connection.closed && + session.connection.rootVerdict !== 'exited' && + session.options?.models.length === 0 ? { reason: 'notSignedIn' } : undefined } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 89eddaa8e0c6..b6f4cd144a8d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -82,6 +82,7 @@ export function NativeChatStructuredSession( queueFollowUps, hostStopping: hostExecution.stopping, providerStarting: hostExecution.phase === 'starting', + providerRunning: hostExecution.phase !== null, rewind: rewindHost, transportEnabled: provisionalLaunch.transportEnabled, ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index 78795324116a..b81ee40ec754 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -37,9 +37,10 @@ import { * read waits for it — one per chat, joined by every later run and remount. * Reports that wait, and why the host's latest answer says no chat can start * (kept until the next answer replaces it; a failed read is unknown). Only - * while it says so, the window gaining focus or a turn starting or ending - * reads again: the host pushes no change, the fix (signing in, installing) - * happens elsewhere, and a started chat makes the host re-check. + * while it says so, the window gaining focus, a turn starting or ending, or + * the chat's agent starting or stopping reads again: the host pushes no change, + * the fix (signing in, installing) happens elsewhere, and a started chat makes + * the host re-check. */ export function useHostModelCatalogUpgrade(args: { agent: AgentType @@ -55,6 +56,8 @@ export function useHostModelCatalogUpgrade(args: { fence: number | null /** The chat's running turn: one running proves its start, which the host re-checks against. */ turnId?: string | null + /** The host runs the chat's agent: a reason its start gave ends with it. */ + providerRunning?: boolean activeOptionRecordRef: MutableRefObject updateOptionState: ( update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState @@ -85,6 +88,15 @@ export function useHostModelCatalogUpgrade(args: { const recheck = useCallback(() => setRereads((count) => count + 1), []) const said = unavailable !== null const turnWhileSaid = said ? (args.turnId ?? null) : null + // A reason the agent's own start gave ends with that agent: its start or stop reads again. + const running = args.providerRunning === true + const [runningSeen, setRunningSeen] = useState(running) + if (runningSeen !== running) { + setRunningSeen(running) + if (said) { + setRereads((count) => count + 1) + } + } useEffect(() => { if (!said) { return diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx index f10c83d2f063..37d38d4f8f90 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx @@ -41,11 +41,11 @@ const mutate = vi.fn(async () => null) as unknown as StructuredAgentSessionMutat let sessionId = '' let sessionCount = 0 -const NO_TURN: { turnId: string | null } = { turnId: null } +const NO_TURN: { turnId: string | null; providerRunning?: boolean } = { turnId: null } function renderOptions() { return renderHook( - ({ turnId }: { turnId: string | null }) => + ({ turnId, providerRunning }: { turnId: string | null; providerRunning?: boolean }) => useStructuredAgentSessionOptions({ agent: 'codex', sessionId, @@ -55,6 +55,7 @@ function renderOptions() { providerVisible: false, fence: null, turnId, + ...(providerRunning ? { providerRunning } : {}), unloadedTurnRevisions: undefined, mutate, launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' }, heldOptions: {} } @@ -140,6 +141,22 @@ describe("a chat's sign-in verdict", () => { expect(reads.count()).toBe(3) }) + // A verdict the agent's own start gave ends with that agent: the idle sweep stopping it is read. + it("reads again when the chat's agent stops or starts while a verdict is said", async () => { + const reads = catalogReads() + const { result, rerender } = renderOptions() + await act(async () => rerender({ turnId: null, providerRunning: true })) + await reads.answer(0, { origin: 'unknown', unavailable: SIGNED_OUT }) + expect(result.current.unavailable).toEqual(SIGNED_OUT) + await act(async () => rerender({ turnId: null, providerRunning: false })) + expect(reads.count()).toBe(2) + await reads.answer(1, { origin: 'unknown' }) + expect(result.current.unavailable).toBeNull() + // With nothing said, the agent starting again reads nothing more. + await act(async () => rerender({ turnId: null, providerRunning: true })) + expect(reads.count()).toBe(2) + }) + it('a turn starting with no verdict reads nothing more', async () => { const reads = catalogReads() const { rerender } = renderOptions() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index eb910e4fb480..3de70fc2cbac 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -43,6 +43,7 @@ export function useStructuredAgentSessionOptions(args: { isVisible: boolean providerVisible: boolean providerStarting?: boolean + providerRunning?: boolean fence: number | null turnId: string | null unloadedTurnRevisions: number | undefined @@ -101,6 +102,7 @@ export function useStructuredAgentSessionOptions(args: { ...(launch?.worktree ? { worktree: launch.worktree } : {}), fence, turnId, + ...(args.providerRunning ? { providerRunning: true } : {}), activeOptionRecordRef, updateOptionState }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 0efcd8074e24..5c5f4615057e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -51,6 +51,8 @@ export function useStructuredAgentSession(args: { transportEnabled?: boolean /** The host has published the session but its provider has not answered startup yet. */ providerStarting?: boolean + /** The host runs the session's provider, started or not. */ + providerRunning?: boolean /** This view started the session; only then does the stored selection name what it runs. */ launch?: StructuredAgentSessionLaunchView /** The composer Edit copies a card's text into. */ @@ -108,6 +110,7 @@ export function useStructuredAgentSession(args: { isVisible, providerVisible, providerStarting, + ...(args.providerRunning ? { providerRunning: true } : {}), fence: state.fence, turnId: transportState.turnId, unloadedTurnRevisions: state.unloadedTurnRevisions, From 7c2ede393382830751e51c1da52bf29672da1bb4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:02:40 -0700 Subject: [PATCH 3/5] Keep one sign-in line after a refused send; re-read on every agent start or stop The notice steps aside while the newest send was refused for the same reason, and the chat re-reads its verdict whenever its agent starts or stops, dropping an answer read before. --- .../NativeChatStructuredSession.tsx | 3 +- .../use-host-model-catalog-upgrade.ts | 21 ++++------ ...model-catalog-upgrade.unavailable.test.tsx | 22 ++++++++--- ...e-native-chat-availability-notice.test.tsx | 23 +++++++++++ .../use-native-chat-availability-notice.ts | 38 +++++++++++++++++-- 5 files changed, 84 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index b6f4cd144a8d..737f277ac278 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -218,7 +218,8 @@ export function NativeChatStructuredSession( agent: props.agent, agentLabel, launchFailure: provisionalLaunch.lifecycle === 'failed' ? provisionalLaunch.failure : null, - journalItems: controller.journalItems + journalItems: controller.journalItems, + submissions: controller.submissions }) const notices = structuredSessionNotices({ launch, diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index b81ee40ec754..a1b7b3336828 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -36,11 +36,11 @@ import { * When the host says its first listing for the account is running, one more * read waits for it — one per chat, joined by every later run and remount. * Reports that wait, and why the host's latest answer says no chat can start - * (kept until the next answer replaces it; a failed read is unknown). Only - * while it says so, the window gaining focus, a turn starting or ending, or - * the chat's agent starting or stopping reads again: the host pushes no change, - * the fix (signing in, installing) happens elsewhere, and a started chat makes - * the host re-check. + * (kept until the next answer replaces it; a failed read is unknown). The + * chat's agent starting or stopping reads again; only while a reason is said, + * the window gaining focus or a turn starting or ending does too: the host + * pushes no change, the fix (signing in, installing) happens elsewhere, and a + * started chat makes the host re-check. */ export function useHostModelCatalogUpgrade(args: { agent: AgentType @@ -88,15 +88,9 @@ export function useHostModelCatalogUpgrade(args: { const recheck = useCallback(() => setRereads((count) => count + 1), []) const said = unavailable !== null const turnWhileSaid = said ? (args.turnId ?? null) : null - // A reason the agent's own start gave ends with that agent: its start or stop reads again. + // A reason the agent's own start gave ends with that agent: its start or stop reads again, + // dropping an answer read before it. const running = args.providerRunning === true - const [runningSeen, setRunningSeen] = useState(running) - if (runningSeen !== running) { - setRunningSeen(running) - if (said) { - setRereads((count) => count + 1) - } - } useEffect(() => { if (!said) { return @@ -175,6 +169,7 @@ export function useHostModelCatalogUpgrade(args: { namesDefault, optionCatalog, rereads, + running, sessionId, turnWhileSaid, target, diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx index 37d38d4f8f90..8de13e2671d3 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.unavailable.test.tsx @@ -142,19 +142,29 @@ describe("a chat's sign-in verdict", () => { }) // A verdict the agent's own start gave ends with that agent: the idle sweep stopping it is read. - it("reads again when the chat's agent stops or starts while a verdict is said", async () => { + it("reads again when the chat's agent stops or starts", async () => { const reads = catalogReads() const { result, rerender } = renderOptions() await act(async () => rerender({ turnId: null, providerRunning: true })) - await reads.answer(0, { origin: 'unknown', unavailable: SIGNED_OUT }) + expect(reads.count()).toBe(2) + await reads.answer(1, { origin: 'unknown', unavailable: SIGNED_OUT }) expect(result.current.unavailable).toEqual(SIGNED_OUT) await act(async () => rerender({ turnId: null, providerRunning: false })) - expect(reads.count()).toBe(2) - await reads.answer(1, { origin: 'unknown' }) + expect(reads.count()).toBe(3) + await reads.answer(2, { origin: 'unknown' }) expect(result.current.unavailable).toBeNull() - // With nothing said, the agent starting again reads nothing more. + }) + + it('drops a signed-out answer read before the agent stopped', async () => { + const reads = catalogReads() + const { result, rerender } = renderOptions() await act(async () => rerender({ turnId: null, providerRunning: true })) - expect(reads.count()).toBe(2) + await act(async () => rerender({ turnId: null, providerRunning: false })) + expect(reads.count()).toBe(3) + await reads.answer(1, { origin: 'unknown', unavailable: SIGNED_OUT }) + expect(result.current.unavailable).toBeNull() + await reads.answer(2, { origin: 'unknown' }) + expect(result.current.unavailable).toBeNull() }) it('a turn starting with no verdict reads nothing more', async () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx index 6efaa5c82634..854cc87de896 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx @@ -81,3 +81,26 @@ it('keeps one auth explanation while an unechoed message still reads as unsent', }) expect([...notices.values()].map((notice) => notice.text)).toEqual(['Your message was not sent.']) }) + +// A signed-out Pi turns the send away before any turn: that send's own line says it. +it("steps aside while the newest send was turned away for the notice's reason", () => { + const sent = (submittedAt: number, rejected?: 'notSignedIn' | 'providerRejected') => ({ + submittedAt, + dispatchState: rejected ? ('rejected' as const) : ('accepted' as const), + ...(rejected ? { rejection: { kind: rejected } } : {}) + }) + const notice = (submissions: ReturnType[]) => + renderHook(() => + useNativeChatAvailabilityNotice({ + agent: 'pi', + agentLabel: 'Pi', + unavailable: { reason: 'notSignedIn' }, + launchFailure: null, + journalItems: [], + submissions + }) + ).result.current + expect(notice([sent(1, 'notSignedIn')])).toBeNull() + expect(notice([sent(1, 'providerRejected')])?.text).toContain('/login') + expect(notice([sent(1, 'notSignedIn'), sent(2)])?.text).toContain('/login') +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts index afdd06ad61ef..6eb327c48a26 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts @@ -1,7 +1,10 @@ import { useMemo, useState } from 'react' import type { AgentSessionUnavailable } from '../../../../shared/agent-session-availability' import { readAgentSessionFailureFact } from '../../../../shared/agent-session-failure' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import type { AgentType } from '../../../../shared/agent-status-types' import { agentSessionRefusalReasonWords } from '../../../../shared/agent-session-refusal-reason-words' import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' @@ -26,15 +29,36 @@ function failedStartReason(items: readonly AgentJournalRenderItem[] | undefined) return reason } +/** Why the newest send was turned away, which its own line already says. */ +function newestRejectionReason( + submissions: readonly Pick< + AgentJournalSubmission, + 'dispatchState' | 'rejection' | 'submittedAt' + >[] +): string | null { + const newest = submissions.reduce<(typeof submissions)[number] | null>( + (latest, submission) => + !latest || submission.submittedAt >= latest.submittedAt ? submission : latest, + null + ) + return newest?.dispatchState === 'rejected' + ? (readAgentSessionFailureFact(newest.rejection)?.kind ?? null) + : null +} + /** The host's verdict on why no chat can start here, as a notice that never holds Send: the * verdict can be wrong while a send would work. Dismissed per verdict, so a changed or returning - * one shows again; left out while the chat's failed start already says the same reason. */ + * one shows again; left out while the chat's failed start or newest send already says it. */ export function useNativeChatAvailabilityNotice(input: { unavailable: AgentSessionUnavailable | null | undefined agent: AgentType agentLabel: string launchFailure: AgentSessionWriteRefusal | null journalItems: readonly AgentJournalRenderItem[] | undefined + submissions?: readonly Pick< + AgentJournalSubmission, + 'dispatchState' | 'rejection' | 'submittedAt' + >[] }): NativeChatComposerNotice | null { const { unavailable, journalItems } = input const key = !unavailable @@ -47,12 +71,20 @@ export function useNativeChatAvailabilityNotice(input: { setDismissal({ key, dismissed: false }) } const rowReason = useMemo(() => failedStartReason(journalItems), [journalItems]) + const sendReason = useMemo( + () => newestRejectionReason(input.submissions ?? []), + [input.submissions] + ) if (!unavailable || (dismissal.key === key && dismissal.dismissed)) { return null } const launchWords = input.launchFailure && agentSessionRefusalReasonWords(input.launchFailure) const launchReason = launchWords && 'fact' in launchWords ? launchWords.fact : null - if (launchReason === unavailable.reason || rowReason === unavailable.reason) { + if ( + launchReason === unavailable.reason || + rowReason === unavailable.reason || + sendReason === unavailable.reason + ) { return null } return { From a16e360c0cd06ec9dc737f4de8aeaef18ed5cc9b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:17:31 -0700 Subject: [PATCH 4/5] Step the notice aside only while the refused send's own line is on screen Uses the host's send order and the chat's rendered delivery lines, so a deleted returned card or a trimmed row brings the sign-in notice back. --- .../NativeChatStructuredSession.tsx | 3 +- ...e-native-chat-availability-notice.test.tsx | 35 ++++++++++++++---- .../use-native-chat-availability-notice.ts | 37 ++++++++++--------- 3 files changed, 48 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 737f277ac278..6f1ef23e3ff1 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -219,7 +219,8 @@ export function NativeChatStructuredSession( agentLabel, launchFailure: provisionalLaunch.lifecycle === 'failed' ? provisionalLaunch.failure : null, journalItems: controller.journalItems, - submissions: controller.submissions + submissions: controller.submissions, + deliveryNotices }) const notices = structuredSessionNotices({ launch, diff --git a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx index 854cc87de896..4c03871d54a7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { useNativeChatAvailabilityNotice } from './use-native-chat-availability-notice' import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import { structuredAgentSessionDeliveryNotices, structuredAgentSessionStartFailureFacts @@ -83,13 +84,21 @@ it('keeps one auth explanation while an unechoed message still reads as unsent', }) // A signed-out Pi turns the send away before any turn: that send's own line says it. -it("steps aside while the newest send was turned away for the notice's reason", () => { - const sent = (submittedAt: number, rejected?: 'notSignedIn' | 'providerRejected') => ({ - submittedAt, +it("steps aside while the newest send's own line says the notice's reason", () => { + const sent = ( + id: string, + order: { at: number; sequence?: number }, + rejected?: 'notSignedIn' | 'providerRejected' + ) => ({ + clientMessageId: id, + submittedAt: order.at, + ...(order.sequence === undefined ? {} : { submittedSequence: order.sequence }), dispatchState: rejected ? ('rejected' as const) : ('accepted' as const), ...(rejected ? { rejection: { kind: rejected } } : {}) }) - const notice = (submissions: ReturnType[]) => + const lines = (...ids: string[]) => + new Map(ids.map((id) => [agentJournalSubmissionKey(id), { text: 'line' }])) + const notice = (submissions: ReturnType[], deliveryNotices = lines('a', 'b')) => renderHook(() => useNativeChatAvailabilityNotice({ agent: 'pi', @@ -97,10 +106,20 @@ it("steps aside while the newest send was turned away for the notice's reason", unavailable: { reason: 'notSignedIn' }, launchFailure: null, journalItems: [], - submissions + submissions, + deliveryNotices }) ).result.current - expect(notice([sent(1, 'notSignedIn')])).toBeNull() - expect(notice([sent(1, 'providerRejected')])?.text).toContain('/login') - expect(notice([sent(1, 'notSignedIn'), sent(2)])?.text).toContain('/login') + expect(notice([sent('a', { at: 1 }, 'notSignedIn')])).toBeNull() + expect(notice([sent('a', { at: 1 }, 'providerRejected')])?.text).toContain('/login') + expect(notice([sent('a', { at: 1 }, 'notSignedIn'), sent('b', { at: 2 })])?.text).toContain( + '/login' + ) + // Its line gone (a returned card deleted, the row trimmed): the notice is the only guidance. + expect(notice([sent('a', { at: 1 }, 'notSignedIn')], lines())?.text).toContain('/login') + // The host's send order decides, not a clock that moved back. + expect( + notice([sent('a', { at: 5, sequence: 1 }, 'notSignedIn'), sent('b', { at: 2, sequence: 2 })]) + ?.text + ).toContain('/login') }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts index 6eb327c48a26..16f7e9cd221f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts @@ -6,6 +6,8 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { AgentType } from '../../../../shared/agent-status-types' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { inSendOrder } from '../../../../shared/native-chat-send-order' import { agentSessionRefusalReasonWords } from '../../../../shared/agent-session-refusal-reason-words' import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key' @@ -29,19 +31,19 @@ function failedStartReason(items: readonly AgentJournalRenderItem[] | undefined) return reason } -/** Why the newest send was turned away, which its own line already says. */ -function newestRejectionReason( - submissions: readonly Pick< - AgentJournalSubmission, - 'dispatchState' | 'rejection' | 'submittedAt' - >[] +type NoticeSubmission = Pick< + AgentJournalSubmission, + 'clientMessageId' | 'dispatchState' | 'rejection' | 'submittedAt' | 'submittedSequence' +> + +/** Why the newest send was turned away, while that send's own line on screen says so. */ +function shownRejectionReason( + submissions: readonly NoticeSubmission[], + shownLines: ReadonlyMap ): string | null { - const newest = submissions.reduce<(typeof submissions)[number] | null>( - (latest, submission) => - !latest || submission.submittedAt >= latest.submittedAt ? submission : latest, - null - ) - return newest?.dispatchState === 'rejected' + const newest = inSendOrder(submissions, (submission) => submission).at(-1) + return newest?.dispatchState === 'rejected' && + shownLines.has(agentJournalSubmissionKey(newest.clientMessageId)) ? (readAgentSessionFailureFact(newest.rejection)?.kind ?? null) : null } @@ -55,10 +57,9 @@ export function useNativeChatAvailabilityNotice(input: { agentLabel: string launchFailure: AgentSessionWriteRefusal | null journalItems: readonly AgentJournalRenderItem[] | undefined - submissions?: readonly Pick< - AgentJournalSubmission, - 'dispatchState' | 'rejection' | 'submittedAt' - >[] + submissions?: readonly NoticeSubmission[] + /** The lines under the chat's messages, by message key: a refused send's says why. */ + deliveryNotices?: ReadonlyMap }): NativeChatComposerNotice | null { const { unavailable, journalItems } = input const key = !unavailable @@ -72,8 +73,8 @@ export function useNativeChatAvailabilityNotice(input: { } const rowReason = useMemo(() => failedStartReason(journalItems), [journalItems]) const sendReason = useMemo( - () => newestRejectionReason(input.submissions ?? []), - [input.submissions] + () => shownRejectionReason(input.submissions ?? [], input.deliveryNotices ?? new Map()), + [input.submissions, input.deliveryNotices] ) if (!unavailable || (dismissal.key === key && dismissal.dismissed)) { return null From b99cb63f841383932899ddd43d5873f3b6719dbf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:25:53 -0700 Subject: [PATCH 5/5] Keep the sign-in notice when the refused message is no longer loaded --- ...e-native-chat-availability-notice.test.tsx | 19 ++++++++++++++++-- .../use-native-chat-availability-notice.ts | 20 ++++++++++++++----- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx index 4c03871d54a7..2975db461ee0 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.test.tsx @@ -98,14 +98,26 @@ it("steps aside while the newest send's own line says the notice's reason", () = }) const lines = (...ids: string[]) => new Map(ids.map((id) => [agentJournalSubmissionKey(id), { text: 'line' }])) - const notice = (submissions: ReturnType[], deliveryNotices = lines('a', 'b')) => + const rows = (...ids: string[]): AgentJournalRenderItem[] => + ids.map((id, index) => ({ + itemId: agentJournalSubmissionKey(id), + revision: 1, + sequence: index + 1, + observedAt: index + 1, + body: { kind: 'message', role: 'user', text: id, blocks: [] } + })) + const notice = ( + submissions: ReturnType[], + deliveryNotices = lines('a', 'b'), + journalItems = rows('a', 'b') + ) => renderHook(() => useNativeChatAvailabilityNotice({ agent: 'pi', agentLabel: 'Pi', unavailable: { reason: 'notSignedIn' }, launchFailure: null, - journalItems: [], + journalItems, submissions, deliveryNotices }) @@ -117,6 +129,9 @@ it("steps aside while the newest send's own line says the notice's reason", () = ) // Its line gone (a returned card deleted, the row trimmed): the notice is the only guidance. expect(notice([sent('a', { at: 1 }, 'notSignedIn')], lines())?.text).toContain('/login') + expect(notice([sent('a', { at: 1 }, 'notSignedIn')], lines('a'), rows())?.text).toContain( + '/login' + ) // The host's send order decides, not a clock that moved back. expect( notice([sent('a', { at: 5, sequence: 1 }, 'notSignedIn'), sent('b', { at: 2, sequence: 2 })]) diff --git a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts index 16f7e9cd221f..18b4dad4233e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-availability-notice.ts @@ -36,14 +36,19 @@ type NoticeSubmission = Pick< 'clientMessageId' | 'dispatchState' | 'rejection' | 'submittedAt' | 'submittedSequence' > -/** Why the newest send was turned away, while that send's own line on screen says so. */ +/** Why the newest send was turned away, while that send's own line on screen says so: its row is + * loaded and carries the line. */ function shownRejectionReason( submissions: readonly NoticeSubmission[], - shownLines: ReadonlyMap + shownLines: ReadonlyMap, + items: readonly AgentJournalRenderItem[] ): string | null { const newest = inSendOrder(submissions, (submission) => submission).at(-1) + const key = newest && agentJournalSubmissionKey(newest.clientMessageId) return newest?.dispatchState === 'rejected' && - shownLines.has(agentJournalSubmissionKey(newest.clientMessageId)) + key !== undefined && + shownLines.has(key) && + items.some((item) => item.itemId === key) ? (readAgentSessionFailureFact(newest.rejection)?.kind ?? null) : null } @@ -73,8 +78,13 @@ export function useNativeChatAvailabilityNotice(input: { } const rowReason = useMemo(() => failedStartReason(journalItems), [journalItems]) const sendReason = useMemo( - () => shownRejectionReason(input.submissions ?? [], input.deliveryNotices ?? new Map()), - [input.submissions, input.deliveryNotices] + () => + shownRejectionReason( + input.submissions ?? [], + input.deliveryNotices ?? new Map(), + journalItems ?? [] + ), + [input.submissions, input.deliveryNotices, journalItems] ) if (!unavailable || (dismissal.key === key && dismissal.dismissed)) { return null