@kortix/sdk 0.12.8 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -6
- package/dist/browser/session-sync/session-sync-registry.d.ts +7 -2
- package/dist/browser/session-sync/session-sync-registry.d.ts.map +1 -1
- package/dist/browser/session-sync/session-sync-registry.js +33 -44
- package/dist/browser/session-sync/session-sync-registry.js.map +1 -1
- package/dist/browser/session-sync/session-transcript-cache.d.ts +9 -2
- package/dist/browser/session-sync/session-transcript-cache.d.ts.map +1 -1
- package/dist/browser/session-sync/session-transcript-cache.js +15 -5
- package/dist/browser/session-sync/session-transcript-cache.js.map +1 -1
- package/dist/browser/stores/opencode-compaction-store.d.ts +9 -3
- package/dist/browser/stores/opencode-compaction-store.d.ts.map +1 -1
- package/dist/browser/stores/opencode-compaction-store.js +5 -12
- package/dist/browser/stores/opencode-compaction-store.js.map +1 -1
- package/dist/browser/stores/sandbox-connection-store.d.ts +28 -17
- package/dist/browser/stores/sandbox-connection-store.d.ts.map +1 -1
- package/dist/browser/stores/sandbox-connection-store.js +32 -84
- package/dist/browser/stores/sandbox-connection-store.js.map +1 -1
- package/dist/browser/stores/session-working-store.d.ts +73 -0
- package/dist/browser/stores/session-working-store.d.ts.map +1 -0
- package/dist/browser/stores/session-working-store.js +86 -0
- package/dist/browser/stores/session-working-store.js.map +1 -0
- package/dist/browser/stores/sync-store/types.d.ts +14 -4
- package/dist/browser/stores/sync-store/types.d.ts.map +1 -1
- package/dist/browser/stores/sync-store.d.ts +179 -4
- package/dist/browser/stores/sync-store.d.ts.map +1 -1
- package/dist/browser/stores/sync-store.js +777 -65
- package/dist/browser/stores/sync-store.js.map +1 -1
- package/dist/core/client/kortix.d.ts +95 -0
- package/dist/core/client/kortix.d.ts.map +1 -1
- package/dist/core/client/kortix.js +78 -0
- package/dist/core/client/kortix.js.map +1 -1
- package/dist/core/files/client.d.ts +50 -2
- package/dist/core/files/client.d.ts.map +1 -1
- package/dist/core/files/client.js +253 -44
- package/dist/core/files/client.js.map +1 -1
- package/dist/core/files/types.d.ts +11 -0
- package/dist/core/files/types.d.ts.map +1 -1
- package/dist/core/http/abort-error.d.ts +89 -0
- package/dist/core/http/abort-error.d.ts.map +1 -0
- package/dist/core/http/abort-error.js +190 -0
- package/dist/core/http/abort-error.js.map +1 -0
- package/dist/core/http/api-client.d.ts.map +1 -1
- package/dist/core/http/api-client.js +18 -4
- package/dist/core/http/api-client.js.map +1 -1
- package/dist/core/http/opencode-errors.d.ts +7 -0
- package/dist/core/http/opencode-errors.d.ts.map +1 -1
- package/dist/core/http/opencode-errors.js +45 -13
- package/dist/core/http/opencode-errors.js.map +1 -1
- package/dist/core/rest/platform-client/github-app.d.ts +13 -0
- package/dist/core/rest/platform-client/github-app.d.ts.map +1 -1
- package/dist/core/rest/platform-client/github-app.js +2 -0
- package/dist/core/rest/platform-client/github-app.js.map +1 -1
- package/dist/core/rest/platform-client/members.d.ts +10 -64
- package/dist/core/rest/platform-client/members.d.ts.map +1 -1
- package/dist/core/rest/platform-client/members.js +10 -28
- package/dist/core/rest/platform-client/members.js.map +1 -1
- package/dist/core/rest/projects-client/accounts.d.ts +30 -5
- package/dist/core/rest/projects-client/accounts.d.ts.map +1 -1
- package/dist/core/rest/projects-client/accounts.js +1 -1
- package/dist/core/rest/projects-client/accounts.js.map +1 -1
- package/dist/core/rest/projects-client/apps.d.ts +13 -0
- package/dist/core/rest/projects-client/apps.d.ts.map +1 -1
- package/dist/core/rest/projects-client/apps.js.map +1 -1
- package/dist/core/rest/projects-client/assignments.d.ts +114 -0
- package/dist/core/rest/projects-client/assignments.d.ts.map +1 -0
- package/dist/core/rest/projects-client/assignments.js +91 -0
- package/dist/core/rest/projects-client/assignments.js.map +1 -0
- package/dist/core/rest/projects-client/billing.d.ts +10 -1
- package/dist/core/rest/projects-client/billing.d.ts.map +1 -1
- package/dist/core/rest/projects-client/billing.js +11 -2
- package/dist/core/rest/projects-client/billing.js.map +1 -1
- package/dist/core/rest/projects-client/connectors.d.ts +69 -1
- package/dist/core/rest/projects-client/connectors.d.ts.map +1 -1
- package/dist/core/rest/projects-client/connectors.js +17 -0
- package/dist/core/rest/projects-client/connectors.js.map +1 -1
- package/dist/core/rest/projects-client/files.js +2 -2
- package/dist/core/rest/projects-client/files.js.map +1 -1
- package/dist/core/rest/projects-client/gateway.d.ts +51 -0
- package/dist/core/rest/projects-client/gateway.d.ts.map +1 -1
- package/dist/core/rest/projects-client/gateway.js.map +1 -1
- package/dist/core/rest/projects-client/iam.d.ts +51 -3
- package/dist/core/rest/projects-client/iam.d.ts.map +1 -1
- package/dist/core/rest/projects-client/iam.js +18 -1
- package/dist/core/rest/projects-client/iam.js.map +1 -1
- package/dist/core/rest/projects-client/index.d.ts +1 -0
- package/dist/core/rest/projects-client/index.d.ts.map +1 -1
- package/dist/core/rest/projects-client/index.js +1 -0
- package/dist/core/rest/projects-client/index.js.map +1 -1
- package/dist/core/rest/projects-client/projects.d.ts +1 -1
- package/dist/core/rest/projects-client/projects.d.ts.map +1 -1
- package/dist/core/rest/projects-client/projects.js +2 -0
- package/dist/core/rest/projects-client/projects.js.map +1 -1
- package/dist/core/rest/projects-client/secrets.d.ts +19 -1
- package/dist/core/rest/projects-client/secrets.d.ts.map +1 -1
- package/dist/core/rest/projects-client/secrets.js +1 -1
- package/dist/core/rest/projects-client/secrets.js.map +1 -1
- package/dist/core/rest/projects-client/session-costs.d.ts +33 -0
- package/dist/core/rest/projects-client/session-costs.d.ts.map +1 -1
- package/dist/core/rest/projects-client/session-costs.js.map +1 -1
- package/dist/core/rest/projects-client/sessions.d.ts +261 -2
- package/dist/core/rest/projects-client/sessions.d.ts.map +1 -1
- package/dist/core/rest/projects-client/sessions.js +106 -4
- package/dist/core/rest/projects-client/sessions.js.map +1 -1
- package/dist/core/rest/projects-client/shared.d.ts +10 -1
- package/dist/core/rest/projects-client/shared.d.ts.map +1 -1
- package/dist/core/rest/projects-client/shared.js.map +1 -1
- package/dist/core/rest/projects-client/tokens.d.ts +13 -1
- package/dist/core/rest/projects-client/tokens.d.ts.map +1 -1
- package/dist/core/rest/projects-client/tokens.js +7 -2
- package/dist/core/rest/projects-client/tokens.js.map +1 -1
- package/dist/core/rest/projects-client/triggers.d.ts +69 -2
- package/dist/core/rest/projects-client/triggers.d.ts.map +1 -1
- package/dist/core/rest/projects-client/triggers.js +1 -1
- package/dist/core/rest/projects-client/triggers.js.map +1 -1
- package/dist/core/session/compaction.d.ts +98 -0
- package/dist/core/session/compaction.d.ts.map +1 -0
- package/dist/core/session/compaction.js +108 -0
- package/dist/core/session/compaction.js.map +1 -0
- package/dist/core/session/health.d.ts +22 -0
- package/dist/core/session/health.d.ts.map +1 -1
- package/dist/core/session/health.js +31 -5
- package/dist/core/session/health.js.map +1 -1
- package/dist/core/session/index.d.ts +2 -0
- package/dist/core/session/index.d.ts.map +1 -1
- package/dist/core/session/index.js +2 -0
- package/dist/core/session/index.js.map +1 -1
- package/dist/core/session/message-queue.d.ts +79 -15
- package/dist/core/session/message-queue.d.ts.map +1 -1
- package/dist/core/session/message-queue.js +73 -27
- package/dist/core/session/message-queue.js.map +1 -1
- package/dist/core/session/rewind.d.ts +76 -10
- package/dist/core/session/rewind.d.ts.map +1 -1
- package/dist/core/session/rewind.js +68 -11
- package/dist/core/session/rewind.js.map +1 -1
- package/dist/core/session/working.d.ts +241 -0
- package/dist/core/session/working.d.ts.map +1 -0
- package/dist/core/session/working.js +261 -0
- package/dist/core/session/working.js.map +1 -0
- package/dist/core/session-sync/session-sync-controller.d.ts +35 -20
- package/dist/core/session-sync/session-sync-controller.d.ts.map +1 -1
- package/dist/core/session-sync/session-sync-controller.js +48 -115
- package/dist/core/session-sync/session-sync-controller.js.map +1 -1
- package/dist/core/stream/event-stream.d.ts +22 -0
- package/dist/core/stream/event-stream.d.ts.map +1 -1
- package/dist/core/stream/event-stream.js +132 -36
- package/dist/core/stream/event-stream.js.map +1 -1
- package/dist/core/turns/grouping.d.ts +18 -2
- package/dist/core/turns/grouping.d.ts.map +1 -1
- package/dist/core/turns/grouping.js +45 -2
- package/dist/core/turns/grouping.js.map +1 -1
- package/dist/core/turns/index.d.ts +1 -0
- package/dist/core/turns/index.d.ts.map +1 -1
- package/dist/core/turns/index.js +1 -0
- package/dist/core/turns/index.js.map +1 -1
- package/dist/core/turns/open-turn.d.ts +81 -0
- package/dist/core/turns/open-turn.d.ts.map +1 -0
- package/dist/core/turns/open-turn.js +77 -0
- package/dist/core/turns/open-turn.js.map +1 -0
- package/dist/core/turns/state.js +26 -26
- package/dist/core/turns/state.js.map +1 -1
- package/dist/core/turns/types.d.ts +5 -0
- package/dist/core/turns/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/kortix.esm.min.js +25 -25
- package/dist/kortix.global.js +25 -25
- package/dist/node/server.d.ts.map +1 -1
- package/dist/node/server.js +14 -2
- package/dist/node/server.js.map +1 -1
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +7 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/initial-session-pin.d.ts +17 -0
- package/dist/react/initial-session-pin.d.ts.map +1 -1
- package/dist/react/initial-session-pin.js +58 -0
- package/dist/react/initial-session-pin.js.map +1 -1
- package/dist/react/opencode.d.ts +6 -2
- package/dist/react/opencode.d.ts.map +1 -1
- package/dist/react/opencode.js +5 -1
- package/dist/react/opencode.js.map +1 -1
- package/dist/react/prefetch-session-start.d.ts +10 -4
- package/dist/react/prefetch-session-start.d.ts.map +1 -1
- package/dist/react/prefetch-session-start.js +10 -4
- package/dist/react/prefetch-session-start.js.map +1 -1
- package/dist/react/query-contracts.d.ts +2 -0
- package/dist/react/query-contracts.d.ts.map +1 -1
- package/dist/react/query-contracts.js +2 -0
- package/dist/react/query-contracts.js.map +1 -1
- package/dist/react/query-keys.d.ts +13 -2
- package/dist/react/query-keys.d.ts.map +1 -1
- package/dist/react/query-keys.js +13 -2
- package/dist/react/query-keys.js.map +1 -1
- package/dist/react/session-start-stash.d.ts +25 -0
- package/dist/react/session-start-stash.d.ts.map +1 -1
- package/dist/react/session-start-stash.js +34 -3
- package/dist/react/session-start-stash.js.map +1 -1
- package/dist/react/session-title-sync.d.ts +5 -1
- package/dist/react/session-title-sync.d.ts.map +1 -1
- package/dist/react/session-title-sync.js +16 -4
- package/dist/react/session-title-sync.js.map +1 -1
- package/dist/react/use-can.d.ts +105 -0
- package/dist/react/use-can.d.ts.map +1 -0
- package/dist/react/use-can.js +190 -0
- package/dist/react/use-can.js.map +1 -0
- package/dist/react/use-canonical-opencode-session.d.ts +6 -1
- package/dist/react/use-canonical-opencode-session.d.ts.map +1 -1
- package/dist/react/use-canonical-opencode-session.js +46 -3
- package/dist/react/use-canonical-opencode-session.js.map +1 -1
- package/dist/react/use-opencode-events/handle-event.d.ts +10 -0
- package/dist/react/use-opencode-events/handle-event.d.ts.map +1 -1
- package/dist/react/use-opencode-events/handle-event.js +76 -7
- package/dist/react/use-opencode-events/handle-event.js.map +1 -1
- package/dist/react/use-opencode-events/helpers.d.ts +21 -8
- package/dist/react/use-opencode-events/helpers.d.ts.map +1 -1
- package/dist/react/use-opencode-events/helpers.js +25 -26
- package/dist/react/use-opencode-events/helpers.js.map +1 -1
- package/dist/react/use-opencode-events/index.d.ts.map +1 -1
- package/dist/react/use-opencode-events/index.js +43 -26
- package/dist/react/use-opencode-events/index.js.map +1 -1
- package/dist/react/use-opencode-events/use-event-stream-refs.d.ts.map +1 -1
- package/dist/react/use-opencode-events/use-event-stream-refs.js +66 -3
- package/dist/react/use-opencode-events/use-event-stream-refs.js.map +1 -1
- package/dist/react/use-opencode-local.d.ts +17 -0
- package/dist/react/use-opencode-local.d.ts.map +1 -1
- package/dist/react/use-opencode-local.js +41 -7
- package/dist/react/use-opencode-local.js.map +1 -1
- package/dist/react/use-opencode-sessions/messages.d.ts +101 -1
- package/dist/react/use-opencode-sessions/messages.d.ts.map +1 -1
- package/dist/react/use-opencode-sessions/messages.js +481 -63
- package/dist/react/use-opencode-sessions/messages.js.map +1 -1
- package/dist/react/use-project-apps.d.ts +24 -1
- package/dist/react/use-project-apps.d.ts.map +1 -1
- package/dist/react/use-project-apps.js +5 -3
- package/dist/react/use-project-apps.js.map +1 -1
- package/dist/react/use-runtime-boot-stalled.d.ts +32 -0
- package/dist/react/use-runtime-boot-stalled.d.ts.map +1 -0
- package/dist/react/use-runtime-boot-stalled.js +48 -0
- package/dist/react/use-runtime-boot-stalled.js.map +1 -0
- package/dist/react/use-runtime-reconnect.d.ts +67 -1
- package/dist/react/use-runtime-reconnect.d.ts.map +1 -1
- package/dist/react/use-runtime-reconnect.js +116 -18
- package/dist/react/use-runtime-reconnect.js.map +1 -1
- package/dist/react/use-session-prompts.d.ts +113 -0
- package/dist/react/use-session-prompts.d.ts.map +1 -0
- package/dist/react/use-session-prompts.js +250 -0
- package/dist/react/use-session-prompts.js.map +1 -0
- package/dist/react/use-session-send.d.ts +169 -20
- package/dist/react/use-session-send.d.ts.map +1 -1
- package/dist/react/use-session-send.js +183 -30
- package/dist/react/use-session-send.js.map +1 -1
- package/dist/react/use-session-sync.d.ts +35 -2
- package/dist/react/use-session-sync.d.ts.map +1 -1
- package/dist/react/use-session-sync.js +49 -11
- package/dist/react/use-session-sync.js.map +1 -1
- package/dist/react/use-session-working.d.ts +57 -0
- package/dist/react/use-session-working.d.ts.map +1 -0
- package/dist/react/use-session-working.js +178 -0
- package/dist/react/use-session-working.js.map +1 -0
- package/dist/react/use-session.d.ts +131 -5
- package/dist/react/use-session.d.ts.map +1 -1
- package/dist/react/use-session.js +358 -69
- package/dist/react/use-session.js.map +1 -1
- package/package.json +2 -2
- package/dist/browser/stores/tab-store.d.ts +0 -91
- package/dist/browser/stores/tab-store.d.ts.map +0 -1
- package/dist/browser/stores/tab-store.js +0 -369
- package/dist/browser/stores/tab-store.js.map +0 -1
- package/dist/core/session/send-queue.d.ts +0 -70
- package/dist/core/session/send-queue.d.ts.map +0 -1
- package/dist/core/session/send-queue.js +0 -116
- package/dist/core/session/send-queue.js.map +0 -1
|
@@ -1,44 +1,27 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
/**
|
|
3
|
-
* useSession — the ONE hook a host needs to open a session and stream a chat.
|
|
4
|
-
*
|
|
5
|
-
* Everything sandbox-shaped is internal: it drives `/start` (server long-poll),
|
|
6
|
-
* points the runtime at the session's sandbox, opens the SSE stream, resolves the
|
|
7
|
-
* canonical OpenCode id, and syncs messages — exposing one `phase`
|
|
8
|
-
* (starting|ready|error) plus messages/send/abort/questions/permissions and the
|
|
9
|
-
* server-side capabilities (models/agents/commands/picks). The host imports
|
|
10
|
-
* `createKortix` + `useSession` and NOTHING else runtime-related: no server-store,
|
|
11
|
-
* no `switchToSessionSandboxAsync`, no health poller, no event-stream provider.
|
|
12
|
-
*
|
|
13
|
-
* Readiness comes from the SERVER (`stage==='ready'` is only returned after the
|
|
14
|
-
* daemon answered), seeded into the connection store on switch — so there is NO
|
|
15
|
-
* client health poller, and the first turn streams immediately.
|
|
16
|
-
*
|
|
17
|
-
* Call this ONCE per session view (like a provider): it owns the SSE subscription
|
|
18
|
-
* and the `/start` poll for `(projectId, sessionId)`.
|
|
19
|
-
*/
|
|
20
2
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
21
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
22
|
-
import { beginSessionPromptObservation, endSessionPromptObservation, } from '../browser/session-sync/session-sync-registry.js';
|
|
3
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
23
4
|
import { useOpenCodeCompactionStore } from '../browser/stores/opencode-compaction-store.js';
|
|
5
|
+
import { useSessionWorkingStore } from '../browser/stores/session-working-store.js';
|
|
6
|
+
import { compactionExpiryAtMs, projectCompacting, serverCompactionRevalidateAtMs, } from '../core/session/compaction.js';
|
|
24
7
|
import { useOpenCodePendingStore } from '../browser/stores/opencode-pending-store.js';
|
|
25
|
-
import { setOpenCodeHealth, setSandboxStatus } from '../browser/stores/sandbox-connection-store.js';
|
|
8
|
+
import { markRuntimeReadyVerified, setOpenCodeHealth, setSandboxStatus, } from '../browser/stores/sandbox-connection-store.js';
|
|
26
9
|
import { getSandboxUrlForExternalId } from '../browser/stores/server-store.js';
|
|
27
|
-
import { useSyncStore } from '../browser/stores/sync-store.js';
|
|
10
|
+
import { ascendingId, useSyncStore } from '../browser/stores/sync-store.js';
|
|
28
11
|
import { BillingError, parseBillingError } from '../core/http/api/errors.js';
|
|
29
12
|
import { isSessionFresh } from '../core/http/fresh-sessions.js';
|
|
30
13
|
import { formatOpenCodeRuntimeError } from '../core/http/opencode-errors.js';
|
|
31
14
|
import { isSessionStartError, sessionStartKey, startProjectSession, } from '../core/rest/projects-client/index.js';
|
|
32
15
|
import { RuntimeNotReadyError, getClient } from '../core/runtime/client.js';
|
|
33
16
|
import { setCurrentRuntime } from '../core/session/current-runtime.js';
|
|
34
|
-
import {
|
|
17
|
+
import { messagesBeforeRewind } from '../core/session/rewind.js';
|
|
35
18
|
import { extractGatewayErrorDetails } from '../core/turns/errors.js';
|
|
36
19
|
import { clearStartStash, readStartStash } from './session-start-stash.js';
|
|
37
20
|
import { reconcileHydratedSessionTitle } from './session-title-sync.js';
|
|
38
21
|
import { useCanonicalOpenCodeSession } from './use-canonical-opencode-session.js';
|
|
39
22
|
import { useOpenCodeEventStream } from './use-opencode-events/index.js';
|
|
40
23
|
import { formatModelString } from './use-opencode-local.js';
|
|
41
|
-
import { rejectQuestion as rejectQuestionApi, replyToPermission, replyToQuestion, useAbortOpenCodeSession, useExecuteOpenCodeCommand, useSendOpenCodeMessage, } from './use-opencode-sessions/index.js';
|
|
24
|
+
import { abortInFlightDeliveries, awaitAbortSettlement, opencodeKeys, rejectQuestion as rejectQuestionApi, replyToPermission, replyToQuestion, useAbortOpenCodeSession, useExecuteOpenCodeCommand, useOpenCodeSession, useSendOpenCodeMessage, } from './use-opencode-sessions/index.js';
|
|
42
25
|
import { unwrap } from './use-opencode-sessions/shared.js';
|
|
43
26
|
import { usePermissionSelfHeal } from './use-permission-self-heal.js';
|
|
44
27
|
import { useProjectConfig } from './use-project-config.js';
|
|
@@ -48,7 +31,14 @@ import { useRuntimePhase } from './use-runtime-phase.js';
|
|
|
48
31
|
import { useSessionPicks } from './use-session-picks.js';
|
|
49
32
|
import { derivePhase } from './use-session-phase.js';
|
|
50
33
|
import { useSessionSync } from './use-session-sync.js';
|
|
34
|
+
import { useSessionWorking } from './use-session-working.js';
|
|
51
35
|
import { useVisibleAgents } from './use-visible-agents.js';
|
|
36
|
+
// `useLayoutEffect` warns during SSR ("useLayoutEffect does nothing on the
|
|
37
|
+
// server"). This file is always `'use client'`, but Next.js still runs an
|
|
38
|
+
// initial SSR pass before hydration — fall back to `useEffect` there. See the
|
|
39
|
+
// mount-time `markRuntimeReadyVerified` seed below for why this hook needs a
|
|
40
|
+
// LAYOUT effect specifically (ordering, not paint timing).
|
|
41
|
+
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
52
42
|
// Grace window for the optimistic create-vs-start race: how long /start keeps
|
|
53
43
|
// retrying a 404 for a freshly-minted session before treating it as terminal.
|
|
54
44
|
// ~12 × 800ms ≈ 9.6s, comfortably past the sub-second create POST.
|
|
@@ -211,6 +201,63 @@ export function computeStartSettled(input) {
|
|
|
211
201
|
return false;
|
|
212
202
|
return !shouldPollSessionStart(input.error, input.data);
|
|
213
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* How long a `ready` /start result stays fresh enough that a REMOUNT renders
|
|
206
|
+
* it straight from the TanStack Query cache with zero network `/start` before
|
|
207
|
+
* first paint — the "user just left this exact session and came right back"
|
|
208
|
+
* case (tab switch, back button, re-opening the same session from the
|
|
209
|
+
* project's session list), which otherwise re-POSTs `/start` (and re-waits
|
|
210
|
+
* its up-to-`waitMs` server long-poll) on every single mount even though the
|
|
211
|
+
* box never went anywhere.
|
|
212
|
+
*
|
|
213
|
+
* The server parks an idle sandbox after ~15 minutes (see the `/start` route
|
|
214
|
+
* this hook drives, in `session-sandbox.ts`), so this window is nowhere near
|
|
215
|
+
* long enough to promise anything about a sandbox that has genuinely been
|
|
216
|
+
* idle — it only needs to outlast the sub-second "switch thrash" case, not
|
|
217
|
+
* survive a real absence. 30s comfortably covers that without risking a
|
|
218
|
+
* materially stale "ready" read surviving into a box the server already
|
|
219
|
+
* parked: `sessionStartStaleTime` only skips the IMMEDIATE refetch, it never
|
|
220
|
+
* cancels one — a background refetch still fires once the cached entry ages
|
|
221
|
+
* past this window (stale-while-revalidate), so a parked box still converges
|
|
222
|
+
* to `stopped` on the very next look.
|
|
223
|
+
*/
|
|
224
|
+
export const SESSION_START_FRESH_MS = 30_000;
|
|
225
|
+
/**
|
|
226
|
+
* OUTCOME-AWARE `staleTime` for the `/start` query (TanStack's function
|
|
227
|
+
* form). Only a `stage==='ready'` result may be served from cache without an
|
|
228
|
+
* immediate refetch — every other stage (`provisioning`/`starting`/
|
|
229
|
+
* `stopped`/`failed`, or no cached data yet) represents an in-flight or
|
|
230
|
+
* terminal state that must keep converging on server truth, so it stays
|
|
231
|
+
* maximally stale (`0`, i.e. TanStack's "always refetch on mount") —
|
|
232
|
+
* unchanged from this query's behavior before this staleTime existed.
|
|
233
|
+
*
|
|
234
|
+
* This does NOT change `refetchInterval` (`SESSION_START_POLL_OPTIONS`,
|
|
235
|
+
* below, untouched): a `ready` result already stops that poll
|
|
236
|
+
* (`shouldPollSessionStart`), so this only governs remount behavior, never
|
|
237
|
+
* steady-state polling.
|
|
238
|
+
*/
|
|
239
|
+
export function sessionStartStaleTime(query) {
|
|
240
|
+
return query.state.data?.stage === 'ready' ? SESSION_START_FRESH_MS : 0;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Whether a cached `/start` query STATE (as sitting in the TanStack Query
|
|
244
|
+
* cache the moment a session starts mounting) already says `stage==='ready'`
|
|
245
|
+
* — independent of staleness/age: React Query serves whatever is cached
|
|
246
|
+
* synchronously on every mount regardless of `staleTime` (`staleTime` only
|
|
247
|
+
* gates whether a background refetch ALSO fires, never whether the cached
|
|
248
|
+
* data renders) — so a session left seconds or minutes ago can make
|
|
249
|
+
* `switched` true well before this mount's own effects have a chance to run.
|
|
250
|
+
*
|
|
251
|
+
* Pure so the mount-time `markRuntimeReadyVerified()` decision it feeds (the
|
|
252
|
+
* `useIsomorphicLayoutEffect` in {@link useSession}, right after the /start
|
|
253
|
+
* query below) is unit-testable without rendering the hook — this package has
|
|
254
|
+
* no hook-render harness, so pure predicates like this ARE the test surface
|
|
255
|
+
* for effect-gated logic (see `computeStartSettled`/`nextInconclusiveSince`
|
|
256
|
+
* above for the established pattern).
|
|
257
|
+
*/
|
|
258
|
+
export function cachedStartResultIsReady(cachedState) {
|
|
259
|
+
return cachedState?.data?.stage === 'ready';
|
|
260
|
+
}
|
|
214
261
|
/**
|
|
215
262
|
* TanStack Query pauses interval fetches while the document is hidden unless
|
|
216
263
|
* this option is true. Session readiness must continue because it gates the
|
|
@@ -238,7 +285,7 @@ const RUNTIME_NOT_READY_MARKER = 'Server URL not ready';
|
|
|
238
285
|
*
|
|
239
286
|
* A no-op when the parts carry no ids or belong to no optimistic message.
|
|
240
287
|
*/
|
|
241
|
-
function markDispatchedForPartIds(sessionId, parts) {
|
|
288
|
+
export function markDispatchedForPartIds(sessionId, parts) {
|
|
242
289
|
const partIds = new Set(parts
|
|
243
290
|
.map((p) => p.id)
|
|
244
291
|
.filter((id) => typeof id === 'string' && !!id));
|
|
@@ -253,6 +300,60 @@ function markDispatchedForPartIds(sessionId, parts) {
|
|
|
253
300
|
store.markOptimisticDispatched(sessionId, message.id);
|
|
254
301
|
}
|
|
255
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* Optimistically add the user's message for a plain-text `send()` call, and
|
|
305
|
+
* mint the SAME part id onto both the optimistic part AND the outgoing wire
|
|
306
|
+
* part `send()` goes on to POST.
|
|
307
|
+
*
|
|
308
|
+
* T15 — "one user bubble": `send()` used to build its outgoing part
|
|
309
|
+
* as `{ type: 'text', text }` with no id at all, so — even where a host paired
|
|
310
|
+
* it with its own optimistic add — nothing correlated the two. The sync
|
|
311
|
+
* store's `message.updated` handler matches an incoming echo to an optimistic
|
|
312
|
+
* message primarily by EXACT part id (opencode preserves a client-supplied
|
|
313
|
+
* part id on echo); only when that fails does it fall back to "the oldest
|
|
314
|
+
* DISPATCHED optimistic message" (`markDispatchedForPartIds`, called
|
|
315
|
+
* unconditionally by `sendParts` below, marks it — but only once the part ids
|
|
316
|
+
* actually match, same root cause). With no id on the wire, neither path can
|
|
317
|
+
* ever fire, and the user's text stayed doubled on screen until the next
|
|
318
|
+
* idle sweep (`clearOptimisticMessages`).
|
|
319
|
+
*
|
|
320
|
+
* Mirrors `beginOptimisticSend` (`use-session-send.ts`) exactly — same store
|
|
321
|
+
* calls, same stub-message shape, same "skip parts for empty/whitespace text"
|
|
322
|
+
* rule — kept as a sibling here rather than imported, since
|
|
323
|
+
* `use-session-send.ts` already imports FROM this module (`classifySendError`)
|
|
324
|
+
* and a reverse import would make the two files mutually dependent.
|
|
325
|
+
*/
|
|
326
|
+
export function beginOptimisticPlainTextSend(sessionId, text) {
|
|
327
|
+
const messageId = ascendingId('msg');
|
|
328
|
+
const partId = ascendingId('prt');
|
|
329
|
+
const optimisticParts = text.trim()
|
|
330
|
+
? [
|
|
331
|
+
{
|
|
332
|
+
id: partId,
|
|
333
|
+
sessionID: sessionId,
|
|
334
|
+
messageID: messageId,
|
|
335
|
+
type: 'text',
|
|
336
|
+
text,
|
|
337
|
+
},
|
|
338
|
+
]
|
|
339
|
+
: [];
|
|
340
|
+
const info = {
|
|
341
|
+
id: messageId,
|
|
342
|
+
sessionID: sessionId,
|
|
343
|
+
role: 'user',
|
|
344
|
+
// No `time.created`: display order is by the BOX's `time.created`
|
|
345
|
+
// (`compareMessagesForDisplay`), and a stub stamped from the browser's
|
|
346
|
+
// clock sorted ABOVE real messages whenever the box ran behind it. An
|
|
347
|
+
// untimed stub is "the newest thing the user did", on every clock.
|
|
348
|
+
time: {},
|
|
349
|
+
};
|
|
350
|
+
useSyncStore.getState().optimisticAdd(sessionId, info, optimisticParts);
|
|
351
|
+
// No status write. `sessionStatus` is where the runtime's own SSE frames
|
|
352
|
+
// land, and a fabricated `busy` there outranked a real `GET .../turn` read
|
|
353
|
+
// stamped after it. `sendParts` files a `SendReceipt` instead — the claim
|
|
354
|
+
// this tab is actually entitled to make.
|
|
355
|
+
return { messageId, parts: [{ type: 'text', text, id: partId }] };
|
|
356
|
+
}
|
|
256
357
|
/** The error body, wherever the transport parked it. */
|
|
257
358
|
function errorBody(error) {
|
|
258
359
|
if (!error || typeof error !== 'object')
|
|
@@ -379,23 +480,12 @@ export function sendStateOnStart(text) {
|
|
|
379
480
|
export function sendStateOnError(error) {
|
|
380
481
|
return { pending: null, sendError: classifySendError(error) };
|
|
381
482
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
useSyncStore.getState().setStatus(sessionId, { type: 'idle' });
|
|
389
|
-
}
|
|
390
|
-
export async function sendRestPromptWithObservation(sessionId, runtimeScope, sendPrompt) {
|
|
391
|
-
beginRestPromptObservation(sessionId, runtimeScope);
|
|
392
|
-
try {
|
|
393
|
-
await sendPrompt();
|
|
394
|
-
}
|
|
395
|
-
catch (error) {
|
|
396
|
-
endRestPromptObservation(sessionId, runtimeScope);
|
|
397
|
-
throw error;
|
|
398
|
-
}
|
|
483
|
+
/** The submission's tab-local name: the host's own key when it gave one, else
|
|
484
|
+
* the optimistic part id this send is correlated by. Never sent anywhere —
|
|
485
|
+
* it exists so a `working` state can say WHICH send it is standing on. */
|
|
486
|
+
export function sendReceiptId(sessionId, parts, clientMessageId) {
|
|
487
|
+
const identified = parts.find((part) => 'id' in part && typeof part.id === 'string');
|
|
488
|
+
return clientMessageId ?? identified?.id ?? sessionId;
|
|
399
489
|
}
|
|
400
490
|
export async function rewindOpenCodeSession(sessionId, messageId) {
|
|
401
491
|
if (!messageId)
|
|
@@ -478,6 +568,7 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
478
568
|
retryDelay: (failureCount, error) => isSessionStartError(error) && error.status === 404
|
|
479
569
|
? FRESH_START_404_RETRY_DELAY_MS
|
|
480
570
|
: Math.min(1000 * 2 ** failureCount, 5000),
|
|
571
|
+
staleTime: sessionStartStaleTime,
|
|
481
572
|
...SESSION_START_POLL_OPTIONS,
|
|
482
573
|
});
|
|
483
574
|
const startData = start.data ?? null;
|
|
@@ -486,6 +577,41 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
486
577
|
const sandbox = startData?.sandbox ?? null;
|
|
487
578
|
const startReady = stage === 'ready';
|
|
488
579
|
const terminal = stage === 'failed' || stage === 'stopped';
|
|
580
|
+
// T8 defect 1 — `useRuntimeReconnect`'s first-mount
|
|
581
|
+
// `resetForServerSwitch()` (a plain `useEffect`) unconditionally seeds
|
|
582
|
+
// `status:'connecting', healthy:null` UNLESS `markRuntimeReadyVerified()`
|
|
583
|
+
// already told it the server proved this session ready. Task A's staleTime
|
|
584
|
+
// (above) means a remount can render `stage==='ready'` on its VERY FIRST
|
|
585
|
+
// render — before ANY effect, this hook's own switch/health-seed effects
|
|
586
|
+
// included, has had a chance to run — so whichever effect happens to fire
|
|
587
|
+
// first is not something to gamble on.
|
|
588
|
+
//
|
|
589
|
+
// `useLayoutEffect` is what makes this deterministic regardless of
|
|
590
|
+
// component-tree shape: React runs EVERY layout effect, tree-wide, before
|
|
591
|
+
// ANY passive effect (`useEffect`) fires — so this always wins the race
|
|
592
|
+
// against `useRuntimeReconnect`'s reset, whether that hook lives in a
|
|
593
|
+
// parent, a child, or a sibling of this one.
|
|
594
|
+
//
|
|
595
|
+
// Gated on `[projectId, sessionId]` (session identity), not `[startReady]`:
|
|
596
|
+
// it must only ever answer "was this session ALREADY proven ready at the
|
|
597
|
+
// moment I started mounting" — never "did it become ready later". A real
|
|
598
|
+
// network `/start` completing seconds after this mount's own reset has
|
|
599
|
+
// ALREADY run (and cleared whatever the flag held) would just leave a
|
|
600
|
+
// stale "ready" flag sitting in sessionStorage for the NEXT, unrelated
|
|
601
|
+
// session's first mount to wrongly consume. That later-ready case needs no
|
|
602
|
+
// help from this flag anyway: the health-seed effect (step 3, below)
|
|
603
|
+
// writes the connection store directly once `switched` flips, and
|
|
604
|
+
// `resetForServerSwitch()` never runs a second time to undo it — see
|
|
605
|
+
// `use-runtime-reconnect.test.ts`'s "mount-time ordering" tests for both
|
|
606
|
+
// orderings converging.
|
|
607
|
+
useIsomorphicLayoutEffect(() => {
|
|
608
|
+
if (!startEnabled)
|
|
609
|
+
return;
|
|
610
|
+
const cached = queryClient.getQueryState(sessionStartKey(projectId, sessionId));
|
|
611
|
+
if (cachedStartResultIsReady(cached))
|
|
612
|
+
markRuntimeReadyVerified();
|
|
613
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
614
|
+
}, [projectId, sessionId]);
|
|
489
615
|
// Track how long /start has been returning nothing usable — no data, no
|
|
490
616
|
// error — so `computeStartSettled` can bound the "given up" case (see
|
|
491
617
|
// START_INCONCLUSIVE_GIVE_UP_MS) instead of waiting on a poll that a
|
|
@@ -592,6 +718,30 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
592
718
|
titleRefreshAbortRef.current?.abort();
|
|
593
719
|
titleRefreshAbortRef.current = null;
|
|
594
720
|
}, [projectId, sessionId]);
|
|
721
|
+
// ONE answer to "is this session working?", and it says where it came from.
|
|
722
|
+
//
|
|
723
|
+
// Four machines used to answer it and disagree: a prompt-observation phase
|
|
724
|
+
// machine with a stall timer, a busy override in the sync layer, a 30s
|
|
725
|
+
// safety timeout, and a 5s polling grace. `working` is a pure projection
|
|
726
|
+
// over the server's turn authority, the session's durable prompt inbox, the
|
|
727
|
+
// live stream, and — only until a server source that CAN know about it
|
|
728
|
+
// answers — this tab's own send receipt.
|
|
729
|
+
//
|
|
730
|
+
// Computed BEFORE `useSessionSync` because it is that hook's liveness switch:
|
|
731
|
+
// the transcript poll must follow the projection, not the stream slot a
|
|
732
|
+
// dropped frame can leave idle through a whole turn.
|
|
733
|
+
const working = useSessionWorking(projectId, sessionId, {
|
|
734
|
+
enabled,
|
|
735
|
+
runtimeSessionId: ocSessionId,
|
|
736
|
+
});
|
|
737
|
+
// The receipt lives in a per-session store, not in this component: the
|
|
738
|
+
// composer mounts its own `useSessionWorking` for the same session and they
|
|
739
|
+
// share one `/turn` cache entry. Two private receipts meant the observer
|
|
740
|
+
// without one wrote an uninformed read into that entry and defeated the
|
|
741
|
+
// other's. Nothing clears it on a server answer and nothing needs to — an
|
|
742
|
+
// observation the server could make AFTER accepting the send outranks it —
|
|
743
|
+
// so only the paths that know nothing is coming drop it.
|
|
744
|
+
const { noteSendReceipt, acceptSendReceipt, clearSendReceipt, noteAbortReceipt, settleAbortReceipt } = useSessionWorkingStore.getState();
|
|
595
745
|
// Always call the hook (rules-of-hooks) so it stays in the same position
|
|
596
746
|
// every render, but starve it with an empty session id when the chat engine
|
|
597
747
|
// is off — `useSessionSync('')` fetches/polls nothing (its effects no-op on
|
|
@@ -600,22 +750,29 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
600
750
|
const rawSync = useSessionSync(chatEngine ? ocSessionId : '', {
|
|
601
751
|
kortixSessionScope: `${projectId}/${sessionId}`,
|
|
602
752
|
networkEnabled: switched,
|
|
753
|
+
working: working.state === 'working',
|
|
603
754
|
});
|
|
604
755
|
const sync = chatEngine ? rawSync : DISABLED_CHAT_ENGINE_SYNC;
|
|
605
756
|
const runtimePhase = useRuntimePhase();
|
|
606
|
-
|
|
757
|
+
// T22 — the revert record lives in the sync store, not component state.
|
|
758
|
+
// Component state made the boundary math wrong in two ways at once: it
|
|
759
|
+
// reset on every remount (stranding an invisible staged revert after a
|
|
760
|
+
// reload — the server still held the pointer, nothing here knew), and it
|
|
761
|
+
// was the ONLY thing to mirror server truth, so a `.staged`/`.cleared`/
|
|
762
|
+
// `.committed` wire event or another tab's action never reached this
|
|
763
|
+
// session's view at all. The store is the single place all of those
|
|
764
|
+
// sources (this hook's own `rewind()`/`restoreRewind()`, the three wire
|
|
765
|
+
// events via `handle-event.ts`, and a `Session.revert` field read on
|
|
766
|
+
// reload) converge — see `sync-store.ts`'s `sessionRevert` doc comment.
|
|
767
|
+
const restRewind = useSyncStore((s) => s.sessionRevert[ocSessionId] ?? null);
|
|
607
768
|
const [rewindPending, setRewindPending] = useState(false);
|
|
608
769
|
const [rewindError, setRewindError] = useState(null);
|
|
609
770
|
const rewindMessageId = restRewind?.staged ? restRewind.messageId : null;
|
|
610
|
-
const messages = useMemo(() => messagesBeforeRewind(sync.messages, restRewind
|
|
771
|
+
const messages = useMemo(() => messagesBeforeRewind(sync.messages, restRewind), [sync.messages, restRewind]);
|
|
611
772
|
useEffect(() => {
|
|
612
|
-
setRestRewind(null);
|
|
613
773
|
setRewindPending(false);
|
|
614
774
|
setRewindError(null);
|
|
615
775
|
}, [sessionId, ocSessionId]);
|
|
616
|
-
useEffect(() => {
|
|
617
|
-
setRestRewind((current) => reconcileCommittedSessionRewind(sync.messages, current));
|
|
618
|
-
}, [sync.messages]);
|
|
619
776
|
// 5b. Self-heal a missed `question.asked` SSE event (a `question` tool part
|
|
620
777
|
// rendering as running with nothing in the pending store) — see
|
|
621
778
|
// `useQuestionSelfHeal` for why this is distinct from the SSE reconnect-gap
|
|
@@ -632,7 +789,59 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
632
789
|
// keyed by request id carrying sessionID). useSessionSync does NOT surface them.
|
|
633
790
|
const questionMap = useOpenCodePendingStore((s) => s.questions);
|
|
634
791
|
const permissionMap = useOpenCodePendingStore((s) => s.permissions);
|
|
635
|
-
|
|
792
|
+
// Is this session COMPACTING? Two inputs, one projection — see
|
|
793
|
+
// `core/session/compaction.ts`. It used to be the raw client-only flag,
|
|
794
|
+
// cleared ONLY by the `session.compacted` SSE frame, so a missed frame pinned
|
|
795
|
+
// the composer for the lifetime of the tab and a compaction started anywhere
|
|
796
|
+
// else was invisible.
|
|
797
|
+
const optimisticCompactionAtMs = useOpenCodeCompactionStore((state) => switched ? (state.compactingBySession[ocSessionId] ?? null) : null);
|
|
798
|
+
// `Session.time.compacting` — the runtime's own record. This query is the
|
|
799
|
+
// same cache entry `session.compacted` and `session.updated` already write
|
|
800
|
+
// (`opencodeKeys.runtimeSession`), so reading it here costs no extra request
|
|
801
|
+
// in a host that mounts the session row anyway.
|
|
802
|
+
const runtimeSessionRow = useOpenCodeSession(switched ? ocSessionId : '');
|
|
803
|
+
const compactionInputs = {
|
|
804
|
+
optimisticAtMs: optimisticCompactionAtMs,
|
|
805
|
+
serverCompactingAtMs: runtimeSessionRow.data?.time?.compacting ?? null,
|
|
806
|
+
nowMs: Date.now(),
|
|
807
|
+
};
|
|
808
|
+
const isCompacting = projectCompacting(compactionInputs);
|
|
809
|
+
// The cap has to apply when NOTHING else re-renders — a lost
|
|
810
|
+
// `session.compacted` frame is exactly the case where no further event
|
|
811
|
+
// arrives — so the expiry instant is armed explicitly. Same mechanism, and
|
|
812
|
+
// the same reason, as `useSessionWorking`'s `workingExpiryAtMs` timer.
|
|
813
|
+
const [, setCompactionTick] = useState(0);
|
|
814
|
+
const compactionExpiry = compactionExpiryAtMs(compactionInputs);
|
|
815
|
+
useEffect(() => {
|
|
816
|
+
if (compactionExpiry === null)
|
|
817
|
+
return;
|
|
818
|
+
const timer = setTimeout(() => setCompactionTick((tick) => tick + 1), Math.max(0, compactionExpiry - Date.now()) + 1);
|
|
819
|
+
return () => clearTimeout(timer);
|
|
820
|
+
}, [compactionExpiry]);
|
|
821
|
+
// The server-observed branch of `projectCompacting` (rule 1) is otherwise
|
|
822
|
+
// unbounded: it stays authoritative until `opencodeKeys.runtimeSession`'s
|
|
823
|
+
// cached row is refreshed, and the only event wired to refresh it
|
|
824
|
+
// (`session.compacted`, in `use-opencode-events/handle-event.ts`) is one
|
|
825
|
+
// SSE frame that can be lost — a backgrounded tab, a stream reconnect, or
|
|
826
|
+
// that handler's own refetch failing. Without this, a lost frame pins
|
|
827
|
+
// `isCompacting` — and therefore the composer and slash-commands
|
|
828
|
+
// (`session-chat.tsx`'s `effectiveBusy`) — for the rest of the tab's life.
|
|
829
|
+
// This forces a REAL, retry-backed re-check (through the same query
|
|
830
|
+
// `useOpenCodeSession` above already runs, so it inherits its 3-attempt
|
|
831
|
+
// exponential-backoff retry) once the observed flag has gone unconfirmed
|
|
832
|
+
// for `SERVER_COMPACTION_REVALIDATE_MS` — independent of whether
|
|
833
|
+
// `session.compacted` ever arrives. If the server still reports
|
|
834
|
+
// compacting, the fresh timestamp re-arms this same timer; once it does
|
|
835
|
+
// not, `serverCompactingAtMs` clears and the projection follows.
|
|
836
|
+
const serverCompactionRevalidateAt = serverCompactionRevalidateAtMs(compactionInputs);
|
|
837
|
+
useEffect(() => {
|
|
838
|
+
if (serverCompactionRevalidateAt === null || !switched || !ocSessionId)
|
|
839
|
+
return;
|
|
840
|
+
const timer = setTimeout(() => {
|
|
841
|
+
void queryClient.invalidateQueries({ queryKey: opencodeKeys.runtimeSession(ocSessionId) });
|
|
842
|
+
}, Math.max(0, serverCompactionRevalidateAt - Date.now()));
|
|
843
|
+
return () => clearTimeout(timer);
|
|
844
|
+
}, [serverCompactionRevalidateAt, switched, ocSessionId, queryClient]);
|
|
636
845
|
const removeQuestion = useOpenCodePendingStore((s) => s.removeQuestion);
|
|
637
846
|
const removePermission = useOpenCodePendingStore((s) => s.removePermission);
|
|
638
847
|
const questions = useMemo(() => (switched ? Object.values(questionMap).filter((q) => q.sessionID === ocSessionId) : []), [questionMap, ocSessionId, switched]);
|
|
@@ -669,17 +878,17 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
669
878
|
const [sendState, setSendState] = useState(IDLE_SEND_STATE);
|
|
670
879
|
const pending = sendState.pending;
|
|
671
880
|
const pendingBaseCount = useRef(0);
|
|
881
|
+
// Cleared when the send's echo lands — a real observation. The 30s timeout
|
|
882
|
+
// that used to sit beside this is deleted: no in-repo consumer reads
|
|
883
|
+
// `pending`, and where it could fire it fired wrongly — 30s is shorter than
|
|
884
|
+
// a legitimate inbox path (18.9s Daytona / 24.5s Platinum boot plus the
|
|
885
|
+
// drain). The sync store's optimistic message and the `SendReceipt` carry
|
|
886
|
+
// the state it approximated, each with an honest bound.
|
|
672
887
|
useEffect(() => {
|
|
673
888
|
if (pending && userMsgCount > pendingBaseCount.current) {
|
|
674
889
|
setSendState((s) => (s.pending ? { ...s, pending: null } : s));
|
|
675
890
|
}
|
|
676
891
|
}, [userMsgCount, pending]);
|
|
677
|
-
useEffect(() => {
|
|
678
|
-
if (!pending)
|
|
679
|
-
return;
|
|
680
|
-
const t = setTimeout(() => setSendState((s) => (s.pending ? { ...s, pending: null } : s)), 30_000);
|
|
681
|
-
return () => clearTimeout(t);
|
|
682
|
-
}, [pending]);
|
|
683
892
|
const sendParts = async (parts, override) => {
|
|
684
893
|
if (!runtimeActionReady)
|
|
685
894
|
throw new RuntimeNotReadyError();
|
|
@@ -700,19 +909,48 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
700
909
|
// which is what keeps it on screen for the whole of a slow upload instead
|
|
701
910
|
// of being deleted by a rehydrate that only carries older turns.
|
|
702
911
|
markDispatchedForPartIds(ocSessionId, parts);
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
912
|
+
const receipt = {
|
|
913
|
+
messageId: sendReceiptId(ocSessionId, parts, override?.clientMessageId),
|
|
914
|
+
atMs: Date.now(),
|
|
915
|
+
};
|
|
916
|
+
noteSendReceipt(sessionId, receipt);
|
|
917
|
+
try {
|
|
918
|
+
await sendMutation.mutateAsync({
|
|
919
|
+
sessionId: ocSessionId,
|
|
920
|
+
parts,
|
|
921
|
+
...(Object.keys(opts).length ? { options: opts } : {}),
|
|
922
|
+
...(override?.clientMessageId ? { clientMessageId: override.clientMessageId } : {}),
|
|
923
|
+
});
|
|
924
|
+
// The server has the prompt. From here — and NOT before — a `/turn` read
|
|
925
|
+
// is able to see it, so one is allowed to answer for it.
|
|
926
|
+
acceptSendReceipt(sessionId, receipt.messageId, Date.now());
|
|
927
|
+
}
|
|
928
|
+
catch (error) {
|
|
929
|
+
// The prompt never reached the server, so there is nothing to wait for.
|
|
930
|
+
// Dropping the receipt here is what stops a refused send from claiming
|
|
931
|
+
// `working` for a minute over a turn that will never start. NAMED, so a
|
|
932
|
+
// slow failure cannot drop a later send's receipt while its own request
|
|
933
|
+
// is still on the wire.
|
|
934
|
+
clearSendReceipt(sessionId, receipt.messageId);
|
|
935
|
+
throw error;
|
|
936
|
+
}
|
|
937
|
+
useSyncStore.getState().commitSessionRevert(ocSessionId);
|
|
709
938
|
};
|
|
710
939
|
const send = (text, override) => {
|
|
711
940
|
if (!runtimeActionReady)
|
|
712
941
|
return;
|
|
713
942
|
pendingBaseCount.current = userMsgCount;
|
|
714
943
|
setSendState(sendStateOnStart(text));
|
|
715
|
-
|
|
944
|
+
// T15 — one user bubble: the optimistic message and the outgoing
|
|
945
|
+
// part share one minted id, so the server's echo can be correlated back
|
|
946
|
+
// to it instead of leaving both on screen until the idle sweep.
|
|
947
|
+
const { messageId, parts } = beginOptimisticPlainTextSend(ocSessionId, text);
|
|
948
|
+
void sendParts(parts, override).catch((error) => {
|
|
949
|
+
// The send never reached (or was refused by) the server — nothing to
|
|
950
|
+
// rehydrate, so drop the optimistic message outright rather than leave
|
|
951
|
+
// it stranded forever (mirrors `abandonOptimisticSend`).
|
|
952
|
+
// `sendParts` already dropped this tab's send receipt on this path.
|
|
953
|
+
useSyncStore.getState().optimisticRemove(ocSessionId, messageId);
|
|
716
954
|
setSendState(sendStateOnError(error));
|
|
717
955
|
});
|
|
718
956
|
};
|
|
@@ -727,14 +965,14 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
727
965
|
throw new RuntimeNotReadyError();
|
|
728
966
|
if (!messageId)
|
|
729
967
|
throw new Error('Session rewind requires a message id');
|
|
730
|
-
if (
|
|
968
|
+
if (working.state === 'working' || rewindPending) {
|
|
731
969
|
throw new Error('Cannot rewind a busy session');
|
|
732
970
|
}
|
|
733
971
|
setRewindPending(true);
|
|
734
972
|
setRewindError(null);
|
|
735
973
|
try {
|
|
736
974
|
await rewindOpenCodeSession(ocSessionId, messageId);
|
|
737
|
-
|
|
975
|
+
useSyncStore.getState().stageSessionRevert(ocSessionId, messageId);
|
|
738
976
|
}
|
|
739
977
|
catch (error) {
|
|
740
978
|
const classified = classifySendError(error);
|
|
@@ -754,7 +992,7 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
754
992
|
setRewindError(null);
|
|
755
993
|
try {
|
|
756
994
|
await restoreOpenCodeSessionRewind(ocSessionId);
|
|
757
|
-
|
|
995
|
+
useSyncStore.getState().clearSessionRevert(ocSessionId);
|
|
758
996
|
}
|
|
759
997
|
catch (error) {
|
|
760
998
|
const classified = classifySendError(error);
|
|
@@ -765,15 +1003,45 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
765
1003
|
setRewindPending(false);
|
|
766
1004
|
}
|
|
767
1005
|
};
|
|
768
|
-
// The one true cancel: abort the run AND drop any pending prompt + open
|
|
1006
|
+
// The one true cancel: abort the run AND drop any pending prompt + open
|
|
1007
|
+
// prompts. Returns a promise that settles once the abort is acknowledged —
|
|
1008
|
+
// the mutation resolved (and, per `abortOpenCodeSession`, the session's
|
|
1009
|
+
// status was re-read to confirm idle), the mutation failed after its own
|
|
1010
|
+
// retries, or a bounded ~5s timeout elapsed. See `AbortSettlement`.
|
|
1011
|
+
//
|
|
1012
|
+
// T9: `abortInFlightDeliveries` runs FIRST, synchronously — a prompt
|
|
1013
|
+
// still retrying its boot/wake backoff when the user hits Stop must never
|
|
1014
|
+
// land after this point and run against the old text. The optimistic UI
|
|
1015
|
+
// (busy → idle, questions/permissions cleared) still updates instantly;
|
|
1016
|
+
// only the returned promise is new — a caller that never awaits it sees
|
|
1017
|
+
// exactly the same synchronous effects as before.
|
|
769
1018
|
const cancel = () => {
|
|
770
1019
|
if (runtimeActionReady) {
|
|
771
|
-
|
|
772
|
-
|
|
1020
|
+
clearSendReceipt(sessionId);
|
|
1021
|
+
// The stop's own receipt. The cancel needs a round trip through the
|
|
1022
|
+
// control plane and the daemon before turn authority is released, so
|
|
1023
|
+
// every `/turn` read issued before it settles still reports the doomed
|
|
1024
|
+
// turn — including the one the optimistic idle frame below triggers.
|
|
1025
|
+
// Without this the composer flipped Send back to Stop ~120ms after the
|
|
1026
|
+
// click and stayed there for the whole abort. See `AbortReceipt`.
|
|
1027
|
+
noteAbortReceipt(sessionId, Date.now());
|
|
1028
|
+
// No fabricated idle frame here: the receipt above IS the optimistic
|
|
1029
|
+
// idle, with provenance and a bound. A fabricated frame outranked the
|
|
1030
|
+
// control plane's `/turn` answer in `projectWorking` for the whole
|
|
1031
|
+
// abort round-trip — the laundering this migration removes.
|
|
1032
|
+
abortInFlightDeliveries(ocSessionId);
|
|
773
1033
|
}
|
|
774
1034
|
questions.forEach((q) => removeQuestion(q.id));
|
|
775
1035
|
permissions.forEach((p) => removePermission(p.id));
|
|
776
1036
|
setSendState(IDLE_SEND_STATE);
|
|
1037
|
+
if (!runtimeActionReady)
|
|
1038
|
+
return Promise.resolve({ status: 'skipped' });
|
|
1039
|
+
const settlement = awaitAbortSettlement(() => abortMutation.mutateAsync(ocSessionId));
|
|
1040
|
+
// `awaitAbortSettlement` never rejects — it resolves with how the abort
|
|
1041
|
+
// ended (acknowledged, failed, or timed out). Any of those is the instant
|
|
1042
|
+
// from which a server read can see the abort's effect, or fail to.
|
|
1043
|
+
void settlement.then(() => settleAbortReceipt(sessionId, Date.now()));
|
|
1044
|
+
return settlement;
|
|
777
1045
|
};
|
|
778
1046
|
const runtimeSessionError = canonicalSession.error;
|
|
779
1047
|
const phase = derivePhase({
|
|
@@ -809,6 +1077,13 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
809
1077
|
picks.setModel(stash.model);
|
|
810
1078
|
if (stash.agent)
|
|
811
1079
|
picks.setAgent(stash.agent);
|
|
1080
|
+
// A picks-only stash (`prompt: ''`) seeds the selections above and sends
|
|
1081
|
+
// NOTHING: apps/web's producers now deliver the first prompt as a durable
|
|
1082
|
+
// inbox row (server-side at create, or `startSessionWithPrompt`), and the
|
|
1083
|
+
// stash carries only the model/agent hand-off. A full stash — the
|
|
1084
|
+
// whitelabel-demo producer — still replays here exactly as before.
|
|
1085
|
+
if (!stash.prompt.trim())
|
|
1086
|
+
return;
|
|
812
1087
|
send(stash.prompt, {
|
|
813
1088
|
model: stash.model,
|
|
814
1089
|
agent: stash.agent,
|
|
@@ -840,6 +1115,17 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
840
1115
|
phase,
|
|
841
1116
|
/** Raw /start stage (provisioning|starting|ready|stopped|failed), for boot UI. */
|
|
842
1117
|
stage,
|
|
1118
|
+
/**
|
|
1119
|
+
* The immutable agent this project session was created with, from /start
|
|
1120
|
+
* (`agent_name`). Known from the FIRST /start response — long before the
|
|
1121
|
+
* sandbox is ready — so composers can render the session's real agent
|
|
1122
|
+
* while it boots instead of guessing from the roster. The server
|
|
1123
|
+
* serializes `'default'` when no agent was bound; that is not a real
|
|
1124
|
+
* roster agent, so it surfaces as `null` here.
|
|
1125
|
+
*/
|
|
1126
|
+
agentName: startData?.agent_name && startData.agent_name !== 'default'
|
|
1127
|
+
? startData.agent_name
|
|
1128
|
+
: null,
|
|
843
1129
|
/** The serialized session_sandboxes row from /start (status, metadata, ids), or null. */
|
|
844
1130
|
sandbox,
|
|
845
1131
|
/** True once the runtime is switched in and ready (equivalent to phase==='ready'). */
|
|
@@ -852,7 +1138,10 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
852
1138
|
failure: startData?.failure ?? null,
|
|
853
1139
|
/** Granular boot phase (connecting|booting|ready|unreachable) for detailed UI. */
|
|
854
1140
|
runtimePhase,
|
|
855
|
-
|
|
1141
|
+
/** Provenance-tagged working state — WHICH observation decided it, when,
|
|
1142
|
+
* and for which turn. The one answer; `isBusy` is its boolean face. */
|
|
1143
|
+
working,
|
|
1144
|
+
isBusy: working.state === 'working',
|
|
856
1145
|
isCompacting,
|
|
857
1146
|
isLoading: sync.isLoading,
|
|
858
1147
|
isError: terminal || !!startError || !!runtimeSessionError,
|