@kortix/sdk 0.12.7 → 0.13.0
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 +13 -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/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 +140 -3
- package/dist/browser/stores/sync-store.d.ts.map +1 -1
- package/dist/browser/stores/sync-store.js +425 -36
- package/dist/browser/stores/sync-store.js.map +1 -1
- package/dist/core/client/kortix.d.ts +102 -1
- 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 +28 -4
- package/dist/core/http/api-client.js.map +1 -1
- package/dist/core/http/impersonation.d.ts +58 -0
- package/dist/core/http/impersonation.d.ts.map +1 -0
- package/dist/core/http/impersonation.js +174 -0
- package/dist/core/http/impersonation.js.map +1 -0
- 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 -1
- 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/agent-config.d.ts +21 -0
- package/dist/core/rest/projects-client/agent-config.d.ts.map +1 -1
- package/dist/core/rest/projects-client/agent-config.js +14 -0
- package/dist/core/rest/projects-client/agent-config.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 +80 -1
- package/dist/core/rest/projects-client/billing.d.ts.map +1 -1
- package/dist/core/rest/projects-client/billing.js +61 -2
- package/dist/core/rest/projects-client/billing.js.map +1 -1
- package/dist/core/rest/projects-client/connectors.d.ts +2 -0
- package/dist/core/rest/projects-client/connectors.d.ts.map +1 -1
- 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 +3 -0
- package/dist/core/rest/projects-client/projects.js.map +1 -1
- package/dist/core/rest/projects-client/secrets.d.ts +11 -0
- 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 +253 -4
- package/dist/core/rest/projects-client/sessions.d.ts.map +1 -1
- package/dist/core/rest/projects-client/sessions.js +101 -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/classify.d.ts +5 -2
- package/dist/core/turns/classify.d.ts.map +1 -1
- package/dist/core/turns/classify.js +1 -0
- package/dist/core/turns/classify.js.map +1 -1
- package/dist/core/turns/errors.d.ts +11 -0
- package/dist/core/turns/errors.d.ts.map +1 -1
- package/dist/core/turns/errors.js +49 -2
- package/dist/core/turns/errors.js.map +1 -1
- package/dist/core/turns/grouping.d.ts +32 -2
- package/dist/core/turns/grouping.d.ts.map +1 -1
- package/dist/core/turns/grouping.js +31 -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.d.ts +1 -1
- package/dist/core/turns/state.d.ts.map +1 -1
- package/dist/core/turns/state.js +29 -27
- package/dist/core/turns/state.js.map +1 -1
- package/dist/core/turns/types.d.ts +8 -0
- package/dist/core/turns/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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 +3 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +8 -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-keys.d.ts +8 -2
- package/dist/react/query-keys.d.ts.map +1 -1
- package/dist/react/query-keys.js +8 -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-admin-accounts.d.ts +121 -0
- package/dist/react/use-admin-accounts.d.ts.map +1 -1
- package/dist/react/use-admin-accounts.js +92 -0
- package/dist/react/use-admin-accounts.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-impersonation.d.ts +54 -0
- package/dist/react/use-impersonation.d.ts.map +1 -0
- package/dist/react/use-impersonation.js +91 -0
- package/dist/react/use-impersonation.js.map +1 -0
- 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 +242 -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 +178 -29
- 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 +46 -9
- 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 +122 -5
- package/dist/react/use-session.d.ts.map +1 -1
- package/dist/react/use-session.js +343 -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,56 @@ 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
|
+
time: { created: Date.now() },
|
|
345
|
+
};
|
|
346
|
+
useSyncStore.getState().optimisticAdd(sessionId, info, optimisticParts);
|
|
347
|
+
// No status write. `sessionStatus` is where the runtime's own SSE frames
|
|
348
|
+
// land, and a fabricated `busy` there outranked a real `GET .../turn` read
|
|
349
|
+
// stamped after it. `sendParts` files a `SendReceipt` instead — the claim
|
|
350
|
+
// this tab is actually entitled to make.
|
|
351
|
+
return { messageId, parts: [{ type: 'text', text, id: partId }] };
|
|
352
|
+
}
|
|
256
353
|
/** The error body, wherever the transport parked it. */
|
|
257
354
|
function errorBody(error) {
|
|
258
355
|
if (!error || typeof error !== 'object')
|
|
@@ -379,23 +476,12 @@ export function sendStateOnStart(text) {
|
|
|
379
476
|
export function sendStateOnError(error) {
|
|
380
477
|
return { pending: null, sendError: classifySendError(error) };
|
|
381
478
|
}
|
|
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
|
-
}
|
|
479
|
+
/** The submission's tab-local name: the host's own key when it gave one, else
|
|
480
|
+
* the optimistic part id this send is correlated by. Never sent anywhere —
|
|
481
|
+
* it exists so a `working` state can say WHICH send it is standing on. */
|
|
482
|
+
export function sendReceiptId(sessionId, parts, clientMessageId) {
|
|
483
|
+
const identified = parts.find((part) => 'id' in part && typeof part.id === 'string');
|
|
484
|
+
return clientMessageId ?? identified?.id ?? sessionId;
|
|
399
485
|
}
|
|
400
486
|
export async function rewindOpenCodeSession(sessionId, messageId) {
|
|
401
487
|
if (!messageId)
|
|
@@ -478,6 +564,7 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
478
564
|
retryDelay: (failureCount, error) => isSessionStartError(error) && error.status === 404
|
|
479
565
|
? FRESH_START_404_RETRY_DELAY_MS
|
|
480
566
|
: Math.min(1000 * 2 ** failureCount, 5000),
|
|
567
|
+
staleTime: sessionStartStaleTime,
|
|
481
568
|
...SESSION_START_POLL_OPTIONS,
|
|
482
569
|
});
|
|
483
570
|
const startData = start.data ?? null;
|
|
@@ -486,6 +573,41 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
486
573
|
const sandbox = startData?.sandbox ?? null;
|
|
487
574
|
const startReady = stage === 'ready';
|
|
488
575
|
const terminal = stage === 'failed' || stage === 'stopped';
|
|
576
|
+
// T8 defect 1 — `useRuntimeReconnect`'s first-mount
|
|
577
|
+
// `resetForServerSwitch()` (a plain `useEffect`) unconditionally seeds
|
|
578
|
+
// `status:'connecting', healthy:null` UNLESS `markRuntimeReadyVerified()`
|
|
579
|
+
// already told it the server proved this session ready. Task A's staleTime
|
|
580
|
+
// (above) means a remount can render `stage==='ready'` on its VERY FIRST
|
|
581
|
+
// render — before ANY effect, this hook's own switch/health-seed effects
|
|
582
|
+
// included, has had a chance to run — so whichever effect happens to fire
|
|
583
|
+
// first is not something to gamble on.
|
|
584
|
+
//
|
|
585
|
+
// `useLayoutEffect` is what makes this deterministic regardless of
|
|
586
|
+
// component-tree shape: React runs EVERY layout effect, tree-wide, before
|
|
587
|
+
// ANY passive effect (`useEffect`) fires — so this always wins the race
|
|
588
|
+
// against `useRuntimeReconnect`'s reset, whether that hook lives in a
|
|
589
|
+
// parent, a child, or a sibling of this one.
|
|
590
|
+
//
|
|
591
|
+
// Gated on `[projectId, sessionId]` (session identity), not `[startReady]`:
|
|
592
|
+
// it must only ever answer "was this session ALREADY proven ready at the
|
|
593
|
+
// moment I started mounting" — never "did it become ready later". A real
|
|
594
|
+
// network `/start` completing seconds after this mount's own reset has
|
|
595
|
+
// ALREADY run (and cleared whatever the flag held) would just leave a
|
|
596
|
+
// stale "ready" flag sitting in sessionStorage for the NEXT, unrelated
|
|
597
|
+
// session's first mount to wrongly consume. That later-ready case needs no
|
|
598
|
+
// help from this flag anyway: the health-seed effect (step 3, below)
|
|
599
|
+
// writes the connection store directly once `switched` flips, and
|
|
600
|
+
// `resetForServerSwitch()` never runs a second time to undo it — see
|
|
601
|
+
// `use-runtime-reconnect.test.ts`'s "mount-time ordering" tests for both
|
|
602
|
+
// orderings converging.
|
|
603
|
+
useIsomorphicLayoutEffect(() => {
|
|
604
|
+
if (!startEnabled)
|
|
605
|
+
return;
|
|
606
|
+
const cached = queryClient.getQueryState(sessionStartKey(projectId, sessionId));
|
|
607
|
+
if (cachedStartResultIsReady(cached))
|
|
608
|
+
markRuntimeReadyVerified();
|
|
609
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
610
|
+
}, [projectId, sessionId]);
|
|
489
611
|
// Track how long /start has been returning nothing usable — no data, no
|
|
490
612
|
// error — so `computeStartSettled` can bound the "given up" case (see
|
|
491
613
|
// START_INCONCLUSIVE_GIVE_UP_MS) instead of waiting on a poll that a
|
|
@@ -592,6 +714,30 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
592
714
|
titleRefreshAbortRef.current?.abort();
|
|
593
715
|
titleRefreshAbortRef.current = null;
|
|
594
716
|
}, [projectId, sessionId]);
|
|
717
|
+
// ONE answer to "is this session working?", and it says where it came from.
|
|
718
|
+
//
|
|
719
|
+
// Four machines used to answer it and disagree: a prompt-observation phase
|
|
720
|
+
// machine with a stall timer, a busy override in the sync layer, a 30s
|
|
721
|
+
// safety timeout, and a 5s polling grace. `working` is a pure projection
|
|
722
|
+
// over the server's turn authority, the session's durable prompt inbox, the
|
|
723
|
+
// live stream, and — only until a server source that CAN know about it
|
|
724
|
+
// answers — this tab's own send receipt.
|
|
725
|
+
//
|
|
726
|
+
// Computed BEFORE `useSessionSync` because it is that hook's liveness switch:
|
|
727
|
+
// the transcript poll must follow the projection, not the stream slot a
|
|
728
|
+
// dropped frame can leave idle through a whole turn.
|
|
729
|
+
const working = useSessionWorking(projectId, sessionId, {
|
|
730
|
+
enabled,
|
|
731
|
+
runtimeSessionId: ocSessionId,
|
|
732
|
+
});
|
|
733
|
+
// The receipt lives in a per-session store, not in this component: the
|
|
734
|
+
// composer mounts its own `useSessionWorking` for the same session and they
|
|
735
|
+
// share one `/turn` cache entry. Two private receipts meant the observer
|
|
736
|
+
// without one wrote an uninformed read into that entry and defeated the
|
|
737
|
+
// other's. Nothing clears it on a server answer and nothing needs to — an
|
|
738
|
+
// observation the server could make AFTER accepting the send outranks it —
|
|
739
|
+
// so only the paths that know nothing is coming drop it.
|
|
740
|
+
const { noteSendReceipt, acceptSendReceipt, clearSendReceipt, noteAbortReceipt, settleAbortReceipt } = useSessionWorkingStore.getState();
|
|
595
741
|
// Always call the hook (rules-of-hooks) so it stays in the same position
|
|
596
742
|
// every render, but starve it with an empty session id when the chat engine
|
|
597
743
|
// is off — `useSessionSync('')` fetches/polls nothing (its effects no-op on
|
|
@@ -600,22 +746,29 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
600
746
|
const rawSync = useSessionSync(chatEngine ? ocSessionId : '', {
|
|
601
747
|
kortixSessionScope: `${projectId}/${sessionId}`,
|
|
602
748
|
networkEnabled: switched,
|
|
749
|
+
working: working.state === 'working',
|
|
603
750
|
});
|
|
604
751
|
const sync = chatEngine ? rawSync : DISABLED_CHAT_ENGINE_SYNC;
|
|
605
752
|
const runtimePhase = useRuntimePhase();
|
|
606
|
-
|
|
753
|
+
// T22 — the revert record lives in the sync store, not component state.
|
|
754
|
+
// Component state made the boundary math wrong in two ways at once: it
|
|
755
|
+
// reset on every remount (stranding an invisible staged revert after a
|
|
756
|
+
// reload — the server still held the pointer, nothing here knew), and it
|
|
757
|
+
// was the ONLY thing to mirror server truth, so a `.staged`/`.cleared`/
|
|
758
|
+
// `.committed` wire event or another tab's action never reached this
|
|
759
|
+
// session's view at all. The store is the single place all of those
|
|
760
|
+
// sources (this hook's own `rewind()`/`restoreRewind()`, the three wire
|
|
761
|
+
// events via `handle-event.ts`, and a `Session.revert` field read on
|
|
762
|
+
// reload) converge — see `sync-store.ts`'s `sessionRevert` doc comment.
|
|
763
|
+
const restRewind = useSyncStore((s) => s.sessionRevert[ocSessionId] ?? null);
|
|
607
764
|
const [rewindPending, setRewindPending] = useState(false);
|
|
608
765
|
const [rewindError, setRewindError] = useState(null);
|
|
609
766
|
const rewindMessageId = restRewind?.staged ? restRewind.messageId : null;
|
|
610
|
-
const messages = useMemo(() => messagesBeforeRewind(sync.messages, restRewind
|
|
767
|
+
const messages = useMemo(() => messagesBeforeRewind(sync.messages, restRewind), [sync.messages, restRewind]);
|
|
611
768
|
useEffect(() => {
|
|
612
|
-
setRestRewind(null);
|
|
613
769
|
setRewindPending(false);
|
|
614
770
|
setRewindError(null);
|
|
615
771
|
}, [sessionId, ocSessionId]);
|
|
616
|
-
useEffect(() => {
|
|
617
|
-
setRestRewind((current) => reconcileCommittedSessionRewind(sync.messages, current));
|
|
618
|
-
}, [sync.messages]);
|
|
619
772
|
// 5b. Self-heal a missed `question.asked` SSE event (a `question` tool part
|
|
620
773
|
// rendering as running with nothing in the pending store) — see
|
|
621
774
|
// `useQuestionSelfHeal` for why this is distinct from the SSE reconnect-gap
|
|
@@ -632,7 +785,59 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
632
785
|
// keyed by request id carrying sessionID). useSessionSync does NOT surface them.
|
|
633
786
|
const questionMap = useOpenCodePendingStore((s) => s.questions);
|
|
634
787
|
const permissionMap = useOpenCodePendingStore((s) => s.permissions);
|
|
635
|
-
|
|
788
|
+
// Is this session COMPACTING? Two inputs, one projection — see
|
|
789
|
+
// `core/session/compaction.ts`. It used to be the raw client-only flag,
|
|
790
|
+
// cleared ONLY by the `session.compacted` SSE frame, so a missed frame pinned
|
|
791
|
+
// the composer for the lifetime of the tab and a compaction started anywhere
|
|
792
|
+
// else was invisible.
|
|
793
|
+
const optimisticCompactionAtMs = useOpenCodeCompactionStore((state) => switched ? (state.compactingBySession[ocSessionId] ?? null) : null);
|
|
794
|
+
// `Session.time.compacting` — the runtime's own record. This query is the
|
|
795
|
+
// same cache entry `session.compacted` and `session.updated` already write
|
|
796
|
+
// (`opencodeKeys.runtimeSession`), so reading it here costs no extra request
|
|
797
|
+
// in a host that mounts the session row anyway.
|
|
798
|
+
const runtimeSessionRow = useOpenCodeSession(switched ? ocSessionId : '');
|
|
799
|
+
const compactionInputs = {
|
|
800
|
+
optimisticAtMs: optimisticCompactionAtMs,
|
|
801
|
+
serverCompactingAtMs: runtimeSessionRow.data?.time?.compacting ?? null,
|
|
802
|
+
nowMs: Date.now(),
|
|
803
|
+
};
|
|
804
|
+
const isCompacting = projectCompacting(compactionInputs);
|
|
805
|
+
// The cap has to apply when NOTHING else re-renders — a lost
|
|
806
|
+
// `session.compacted` frame is exactly the case where no further event
|
|
807
|
+
// arrives — so the expiry instant is armed explicitly. Same mechanism, and
|
|
808
|
+
// the same reason, as `useSessionWorking`'s `workingExpiryAtMs` timer.
|
|
809
|
+
const [, setCompactionTick] = useState(0);
|
|
810
|
+
const compactionExpiry = compactionExpiryAtMs(compactionInputs);
|
|
811
|
+
useEffect(() => {
|
|
812
|
+
if (compactionExpiry === null)
|
|
813
|
+
return;
|
|
814
|
+
const timer = setTimeout(() => setCompactionTick((tick) => tick + 1), Math.max(0, compactionExpiry - Date.now()) + 1);
|
|
815
|
+
return () => clearTimeout(timer);
|
|
816
|
+
}, [compactionExpiry]);
|
|
817
|
+
// The server-observed branch of `projectCompacting` (rule 1) is otherwise
|
|
818
|
+
// unbounded: it stays authoritative until `opencodeKeys.runtimeSession`'s
|
|
819
|
+
// cached row is refreshed, and the only event wired to refresh it
|
|
820
|
+
// (`session.compacted`, in `use-opencode-events/handle-event.ts`) is one
|
|
821
|
+
// SSE frame that can be lost — a backgrounded tab, a stream reconnect, or
|
|
822
|
+
// that handler's own refetch failing. Without this, a lost frame pins
|
|
823
|
+
// `isCompacting` — and therefore the composer and slash-commands
|
|
824
|
+
// (`session-chat.tsx`'s `effectiveBusy`) — for the rest of the tab's life.
|
|
825
|
+
// This forces a REAL, retry-backed re-check (through the same query
|
|
826
|
+
// `useOpenCodeSession` above already runs, so it inherits its 3-attempt
|
|
827
|
+
// exponential-backoff retry) once the observed flag has gone unconfirmed
|
|
828
|
+
// for `SERVER_COMPACTION_REVALIDATE_MS` — independent of whether
|
|
829
|
+
// `session.compacted` ever arrives. If the server still reports
|
|
830
|
+
// compacting, the fresh timestamp re-arms this same timer; once it does
|
|
831
|
+
// not, `serverCompactingAtMs` clears and the projection follows.
|
|
832
|
+
const serverCompactionRevalidateAt = serverCompactionRevalidateAtMs(compactionInputs);
|
|
833
|
+
useEffect(() => {
|
|
834
|
+
if (serverCompactionRevalidateAt === null || !switched || !ocSessionId)
|
|
835
|
+
return;
|
|
836
|
+
const timer = setTimeout(() => {
|
|
837
|
+
void queryClient.invalidateQueries({ queryKey: opencodeKeys.runtimeSession(ocSessionId) });
|
|
838
|
+
}, Math.max(0, serverCompactionRevalidateAt - Date.now()));
|
|
839
|
+
return () => clearTimeout(timer);
|
|
840
|
+
}, [serverCompactionRevalidateAt, switched, ocSessionId, queryClient]);
|
|
636
841
|
const removeQuestion = useOpenCodePendingStore((s) => s.removeQuestion);
|
|
637
842
|
const removePermission = useOpenCodePendingStore((s) => s.removePermission);
|
|
638
843
|
const questions = useMemo(() => (switched ? Object.values(questionMap).filter((q) => q.sessionID === ocSessionId) : []), [questionMap, ocSessionId, switched]);
|
|
@@ -669,17 +874,17 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
669
874
|
const [sendState, setSendState] = useState(IDLE_SEND_STATE);
|
|
670
875
|
const pending = sendState.pending;
|
|
671
876
|
const pendingBaseCount = useRef(0);
|
|
877
|
+
// Cleared when the send's echo lands — a real observation. The 30s timeout
|
|
878
|
+
// that used to sit beside this is deleted: no in-repo consumer reads
|
|
879
|
+
// `pending`, and where it could fire it fired wrongly — 30s is shorter than
|
|
880
|
+
// a legitimate inbox path (18.9s Daytona / 24.5s Platinum boot plus the
|
|
881
|
+
// drain). The sync store's optimistic message and the `SendReceipt` carry
|
|
882
|
+
// the state it approximated, each with an honest bound.
|
|
672
883
|
useEffect(() => {
|
|
673
884
|
if (pending && userMsgCount > pendingBaseCount.current) {
|
|
674
885
|
setSendState((s) => (s.pending ? { ...s, pending: null } : s));
|
|
675
886
|
}
|
|
676
887
|
}, [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
888
|
const sendParts = async (parts, override) => {
|
|
684
889
|
if (!runtimeActionReady)
|
|
685
890
|
throw new RuntimeNotReadyError();
|
|
@@ -700,19 +905,48 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
700
905
|
// which is what keeps it on screen for the whole of a slow upload instead
|
|
701
906
|
// of being deleted by a rehydrate that only carries older turns.
|
|
702
907
|
markDispatchedForPartIds(ocSessionId, parts);
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
908
|
+
const receipt = {
|
|
909
|
+
messageId: sendReceiptId(ocSessionId, parts, override?.clientMessageId),
|
|
910
|
+
atMs: Date.now(),
|
|
911
|
+
};
|
|
912
|
+
noteSendReceipt(sessionId, receipt);
|
|
913
|
+
try {
|
|
914
|
+
await sendMutation.mutateAsync({
|
|
915
|
+
sessionId: ocSessionId,
|
|
916
|
+
parts,
|
|
917
|
+
...(Object.keys(opts).length ? { options: opts } : {}),
|
|
918
|
+
...(override?.clientMessageId ? { clientMessageId: override.clientMessageId } : {}),
|
|
919
|
+
});
|
|
920
|
+
// The server has the prompt. From here — and NOT before — a `/turn` read
|
|
921
|
+
// is able to see it, so one is allowed to answer for it.
|
|
922
|
+
acceptSendReceipt(sessionId, receipt.messageId, Date.now());
|
|
923
|
+
}
|
|
924
|
+
catch (error) {
|
|
925
|
+
// The prompt never reached the server, so there is nothing to wait for.
|
|
926
|
+
// Dropping the receipt here is what stops a refused send from claiming
|
|
927
|
+
// `working` for a minute over a turn that will never start. NAMED, so a
|
|
928
|
+
// slow failure cannot drop a later send's receipt while its own request
|
|
929
|
+
// is still on the wire.
|
|
930
|
+
clearSendReceipt(sessionId, receipt.messageId);
|
|
931
|
+
throw error;
|
|
932
|
+
}
|
|
933
|
+
useSyncStore.getState().commitSessionRevert(ocSessionId);
|
|
709
934
|
};
|
|
710
935
|
const send = (text, override) => {
|
|
711
936
|
if (!runtimeActionReady)
|
|
712
937
|
return;
|
|
713
938
|
pendingBaseCount.current = userMsgCount;
|
|
714
939
|
setSendState(sendStateOnStart(text));
|
|
715
|
-
|
|
940
|
+
// T15 — one user bubble: the optimistic message and the outgoing
|
|
941
|
+
// part share one minted id, so the server's echo can be correlated back
|
|
942
|
+
// to it instead of leaving both on screen until the idle sweep.
|
|
943
|
+
const { messageId, parts } = beginOptimisticPlainTextSend(ocSessionId, text);
|
|
944
|
+
void sendParts(parts, override).catch((error) => {
|
|
945
|
+
// The send never reached (or was refused by) the server — nothing to
|
|
946
|
+
// rehydrate, so drop the optimistic message outright rather than leave
|
|
947
|
+
// it stranded forever (mirrors `abandonOptimisticSend`).
|
|
948
|
+
// `sendParts` already dropped this tab's send receipt on this path.
|
|
949
|
+
useSyncStore.getState().optimisticRemove(ocSessionId, messageId);
|
|
716
950
|
setSendState(sendStateOnError(error));
|
|
717
951
|
});
|
|
718
952
|
};
|
|
@@ -727,14 +961,14 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
727
961
|
throw new RuntimeNotReadyError();
|
|
728
962
|
if (!messageId)
|
|
729
963
|
throw new Error('Session rewind requires a message id');
|
|
730
|
-
if (
|
|
964
|
+
if (working.state === 'working' || rewindPending) {
|
|
731
965
|
throw new Error('Cannot rewind a busy session');
|
|
732
966
|
}
|
|
733
967
|
setRewindPending(true);
|
|
734
968
|
setRewindError(null);
|
|
735
969
|
try {
|
|
736
970
|
await rewindOpenCodeSession(ocSessionId, messageId);
|
|
737
|
-
|
|
971
|
+
useSyncStore.getState().stageSessionRevert(ocSessionId, messageId);
|
|
738
972
|
}
|
|
739
973
|
catch (error) {
|
|
740
974
|
const classified = classifySendError(error);
|
|
@@ -754,7 +988,7 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
754
988
|
setRewindError(null);
|
|
755
989
|
try {
|
|
756
990
|
await restoreOpenCodeSessionRewind(ocSessionId);
|
|
757
|
-
|
|
991
|
+
useSyncStore.getState().clearSessionRevert(ocSessionId);
|
|
758
992
|
}
|
|
759
993
|
catch (error) {
|
|
760
994
|
const classified = classifySendError(error);
|
|
@@ -765,15 +999,45 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
765
999
|
setRewindPending(false);
|
|
766
1000
|
}
|
|
767
1001
|
};
|
|
768
|
-
// The one true cancel: abort the run AND drop any pending prompt + open
|
|
1002
|
+
// The one true cancel: abort the run AND drop any pending prompt + open
|
|
1003
|
+
// prompts. Returns a promise that settles once the abort is acknowledged —
|
|
1004
|
+
// the mutation resolved (and, per `abortOpenCodeSession`, the session's
|
|
1005
|
+
// status was re-read to confirm idle), the mutation failed after its own
|
|
1006
|
+
// retries, or a bounded ~5s timeout elapsed. See `AbortSettlement`.
|
|
1007
|
+
//
|
|
1008
|
+
// T9: `abortInFlightDeliveries` runs FIRST, synchronously — a prompt
|
|
1009
|
+
// still retrying its boot/wake backoff when the user hits Stop must never
|
|
1010
|
+
// land after this point and run against the old text. The optimistic UI
|
|
1011
|
+
// (busy → idle, questions/permissions cleared) still updates instantly;
|
|
1012
|
+
// only the returned promise is new — a caller that never awaits it sees
|
|
1013
|
+
// exactly the same synchronous effects as before.
|
|
769
1014
|
const cancel = () => {
|
|
770
1015
|
if (runtimeActionReady) {
|
|
771
|
-
|
|
772
|
-
|
|
1016
|
+
clearSendReceipt(sessionId);
|
|
1017
|
+
// The stop's own receipt. The cancel needs a round trip through the
|
|
1018
|
+
// control plane and the daemon before turn authority is released, so
|
|
1019
|
+
// every `/turn` read issued before it settles still reports the doomed
|
|
1020
|
+
// turn — including the one the optimistic idle frame below triggers.
|
|
1021
|
+
// Without this the composer flipped Send back to Stop ~120ms after the
|
|
1022
|
+
// click and stayed there for the whole abort. See `AbortReceipt`.
|
|
1023
|
+
noteAbortReceipt(sessionId, Date.now());
|
|
1024
|
+
// No fabricated idle frame here: the receipt above IS the optimistic
|
|
1025
|
+
// idle, with provenance and a bound. A fabricated frame outranked the
|
|
1026
|
+
// control plane's `/turn` answer in `projectWorking` for the whole
|
|
1027
|
+
// abort round-trip — the laundering this migration removes.
|
|
1028
|
+
abortInFlightDeliveries(ocSessionId);
|
|
773
1029
|
}
|
|
774
1030
|
questions.forEach((q) => removeQuestion(q.id));
|
|
775
1031
|
permissions.forEach((p) => removePermission(p.id));
|
|
776
1032
|
setSendState(IDLE_SEND_STATE);
|
|
1033
|
+
if (!runtimeActionReady)
|
|
1034
|
+
return Promise.resolve({ status: 'skipped' });
|
|
1035
|
+
const settlement = awaitAbortSettlement(() => abortMutation.mutateAsync(ocSessionId));
|
|
1036
|
+
// `awaitAbortSettlement` never rejects — it resolves with how the abort
|
|
1037
|
+
// ended (acknowledged, failed, or timed out). Any of those is the instant
|
|
1038
|
+
// from which a server read can see the abort's effect, or fail to.
|
|
1039
|
+
void settlement.then(() => settleAbortReceipt(sessionId, Date.now()));
|
|
1040
|
+
return settlement;
|
|
777
1041
|
};
|
|
778
1042
|
const runtimeSessionError = canonicalSession.error;
|
|
779
1043
|
const phase = derivePhase({
|
|
@@ -809,6 +1073,13 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
809
1073
|
picks.setModel(stash.model);
|
|
810
1074
|
if (stash.agent)
|
|
811
1075
|
picks.setAgent(stash.agent);
|
|
1076
|
+
// A picks-only stash (`prompt: ''`) seeds the selections above and sends
|
|
1077
|
+
// NOTHING: apps/web's producers now deliver the first prompt as a durable
|
|
1078
|
+
// inbox row (server-side at create, or `startSessionWithPrompt`), and the
|
|
1079
|
+
// stash carries only the model/agent hand-off. A full stash — the
|
|
1080
|
+
// whitelabel-demo producer — still replays here exactly as before.
|
|
1081
|
+
if (!stash.prompt.trim())
|
|
1082
|
+
return;
|
|
812
1083
|
send(stash.prompt, {
|
|
813
1084
|
model: stash.model,
|
|
814
1085
|
agent: stash.agent,
|
|
@@ -852,7 +1123,10 @@ export function useSession(projectId, sessionId, options = {}) {
|
|
|
852
1123
|
failure: startData?.failure ?? null,
|
|
853
1124
|
/** Granular boot phase (connecting|booting|ready|unreachable) for detailed UI. */
|
|
854
1125
|
runtimePhase,
|
|
855
|
-
|
|
1126
|
+
/** Provenance-tagged working state — WHICH observation decided it, when,
|
|
1127
|
+
* and for which turn. The one answer; `isBusy` is its boolean face. */
|
|
1128
|
+
working,
|
|
1129
|
+
isBusy: working.state === 'working',
|
|
856
1130
|
isCompacting,
|
|
857
1131
|
isLoading: sync.isLoading,
|
|
858
1132
|
isError: terminal || !!startError || !!runtimeSessionError,
|