| 1 | import { lazy, Suspense, type ReactNode } from "react"; |
| 2 | import { Transcript, type TranscriptProps } from "../components/Transcript"; |
| 3 | import { SessionRecoveryBanner, SessionRecoveryPlaceholder } from "../components/SessionRecoveryBanner"; |
| 4 | import { NoticePreviewPanel, noticePreviewMockEnabled } from "./NoticePreviewPanel"; |
| 5 | import type { SidebarImConnection } from "../app-runtime/sidebarImProjection"; |
| 6 | import type { TabMeta } from "../lib/types"; |
| 7 | import type { State } from "../lib/useController"; |
| 8 | import type { RemoteSessionApi } from "../lib/useRemoteSession"; |
| 9 | import type { Translator } from "../lib/i18n"; |
| 10 | import type { ForkBlockReason } from "../lib/forkTargets"; |
| 11 | import type { SessionAvailability } from "../lib/sessionAvailability"; |
| 12 | import { orderedLocalSubmissions } from "../lib/localSubmissionState"; |
| 13 | import { RotateCcw } from "lucide-react"; |
| 14 | import type { SessionDraftSurface } from "../app-runtime/useSessionDraftSurface"; |
| 15 | import { draftSurfaceNeedsAttention } from "./draftPresentation"; |
| 16 | |
| 17 | const RemoteSessionSurface = lazy(() => import("../components/RemoteSessionSurface").then((module) => ({ default: module.RemoteSessionSurface }))); |
| 18 | const SidebarImConnectionDetail = lazy(() => import("./SidebarImConnectionDetail").then((module) => ({ default: module.SidebarImConnectionDetail }))); |
| 19 | |
| 20 | export type ChatPaneTranscriptInput = { |
| 21 | state: State; |
| 22 | items: TranscriptProps["items"]; |
| 23 | tabId: TranscriptProps["tabId"]; |
| 24 | geometrySessionKey: TranscriptProps["geometrySessionKey"]; |
| 25 | footerHeight: TranscriptProps["footerHeight"]; |
| 26 | invocationMetadata: TranscriptProps["invocationMetadata"]; |
| 27 | surfaceCommitToken: TranscriptProps["surfaceCommitToken"]; |
| 28 | liveStore: TranscriptProps["liveStore"]; |
| 29 | transcriptHydrating: boolean; |
| 30 | navigationDataReady: boolean; |
| 31 | readOnly: boolean; |
| 32 | controllerReady: boolean; |
| 33 | hydratePlaceholderActive: boolean; |
| 34 | clearContextPending: boolean; |
| 35 | emptyHero?: boolean; |
| 36 | availability: SessionAvailability; |
| 37 | rewind: { |
| 38 | stateActive: boolean; |
| 39 | committing: boolean; |
| 40 | }; |
| 41 | }; |
| 42 | |
| 43 | export type ChatPaneRegionProps = { |
| 44 | transitioning: boolean; |
| 45 | t: Translator; |
| 46 | imDetail: { |
| 47 | connection: SidebarImConnection; |
| 48 | onClose: () => void; |
| 49 | onOpenSettings: () => void; |
| 50 | onManageAllowlist: (connectionId: string) => void; |
| 51 | onOpenSession: (connection: SidebarImConnection) => void; |
| 52 | } | null; |
| 53 | remote: { tab: TabMeta; session: RemoteSessionApi } | undefined; |
| 54 | draft?: { |
| 55 | surface: SessionDraftSurface; |
| 56 | onUseSaved(): void; |
| 57 | onKeepLocal(): void; |
| 58 | onRetrySave(): void; |
| 59 | onDismissTaskError?(): void; |
| 60 | onResume(): void; |
| 61 | onOpenSession(): void; |
| 62 | onCheckSubmission(): void; |
| 63 | }; |
| 64 | /** Floating dock launcher card, mounted over the transcript's right edge. */ |
| 65 | launcher?: ReactNode; |
| 66 | transcript: ChatPaneTranscriptInput; |
| 67 | onRetryHistory: () => Promise<unknown>; |
| 68 | commands: { |
| 69 | onPrompt: TranscriptProps["onPrompt"]; |
| 70 | onFork: TranscriptProps["onFork"]; |
| 71 | onLoadOlderHistory: TranscriptProps["onLoadOlderHistory"]; |
| 72 | onLoadNewerHistory: TranscriptProps["onLoadNewerHistory"]; |
| 73 | onSurfacePaintReady: TranscriptProps["onSurfacePaintReady"]; |
| 74 | }; |
| 75 | }; |
| 76 | |
| 77 | /** |
| 78 | * The chat-pane main surface: IM/bot detail, notice preview mock, remote |
| 79 | * session surface or the local transcript with its navigation-transition |
| 80 | * wrapper and history-load error. Pure prop-driven; all ownership stays in |
| 81 | * the caller's owners. |
| 82 | */ |
| 83 | export function ChatPaneRegion(props: ChatPaneRegionProps) { |
| 84 | const { transitioning, t, transcript, commands } = props; |
| 85 | const { state, rewind } = transcript; |
| 86 | // A fork entry reads persisted turn records, so it never waits for the session |
| 87 | // to stop running, and a read-only source still forks: the child is written |
| 88 | // from the source, never into it. It does wait for the surface it belongs to: |
| 89 | // while the transcript hydrates or the source identity is switching, the |
| 90 | // records on screen are not yet the ones a cut would address. |
| 91 | const forkBlocked: ForkBlockReason | null = state.forkCreating ? "creating" |
| 92 | : !transcript.controllerReady || transcript.transcriptHydrating || transcript.hydratePlaceholderActive || transitioning |
| 93 | ? "loading" |
| 94 | : null; |
| 95 | const noticePreview = noticePreviewMockEnabled(); |
| 96 | if (props.draft && !props.imDetail && !noticePreview) { |
| 97 | const draft = props.draft.surface; |
| 98 | if (!draftSurfaceNeedsAttention(draft)) { |
| 99 | return <main className="main main--draft-landing" aria-label={t("draft.surfaceLabel")} />; |
| 100 | } |
| 101 | const operationUnknown = draft.operation?.phase === "dispatch_unknown"; |
| 102 | const operationError = draft.operation && ["terminal_failed", "runtime_failed", "resume_required", "dispatch_unknown", "dispatching_shell"].includes(draft.operation.phase) |
| 103 | ? draft.operation.error |
| 104 | : ""; |
| 105 | return <main className="main main--draft-attention"> |
| 106 | <section className="session-draft-attention" aria-label={t("draft.surfaceLabel")} role="alert"> |
| 107 | <div className={`session-draft-surface__status session-draft-surface__status--${draft.saveState}`} role="status"> |
| 108 | {draft.operation?.phase === "accepted" ? t("draft.openSession") : operationUnknown ? t("draft.resultUnknown") |
| 109 | : draft.saveState === "error" ? t("draft.saveFailed") |
| 110 | : draft.saveState === "conflict" ? t("draft.conflict") : t("draft.starting")} |
| 111 | </div> |
| 112 | {draft.saveState === "conflict" ? <div className="session-draft-surface__conflict" role="alert"> |
| 113 | <span>{t("draft.conflictDetail")}</span> |
| 114 | <button type="button" onClick={props.draft.onUseSaved}><RotateCcw size={14} />{t("draft.useSaved")}</button> |
| 115 | <button type="button" onClick={props.draft.onKeepLocal}>{t("draft.keepLocal")}</button> |
| 116 | </div> : null} |
| 117 | {draft.error ? <p className="session-draft-surface__error">{draft.error} <button type="button" onClick={props.draft.onRetrySave}>{t("draft.retrySave")}</button></p> : null} |
| 118 | {operationError ? <p className="session-draft-surface__error">{operationError}</p> : null} |
| 119 | {draft.taskError ? <p className="session-draft-surface__error">{draft.taskError} <button type="button" onClick={props.draft.onDismissTaskError}>{t("common.close")}</button></p> : null} |
| 120 | {draft.operation?.canResume ? <button type="button" onClick={props.draft.onResume}>{t("draft.resume")}</button> : null} |
| 121 | {operationUnknown ? <button type="button" onClick={props.draft.onCheckSubmission}>{t("draft.checkSubmission")}</button> : null} |
| 122 | {draft.operation?.phase === "accepted" ? <button type="button" onClick={props.draft.onOpenSession}>{t("draft.openSession")}</button> : null} |
| 123 | </section> |
| 124 | </main>; |
| 125 | } |
| 126 | if (props.remote && !(props.imDetail && !transitioning) && !noticePreview) { |
| 127 | return <Suspense fallback={null}><RemoteSessionSurface tab={props.remote.tab} session={props.remote.session} |
| 128 | surfaceCommitToken={transcript.surfaceCommitToken} onSurfacePaintReady={commands.onSurfacePaintReady} /></Suspense>; |
| 129 | } |
| 130 | const localSubmissions = orderedLocalSubmissions(state); |
| 131 | const recoveringEmpty = !transitioning && transcript.availability.kind !== "ready" && transcript.items.length === 0 && localSubmissions.length === 0 |
| 132 | && !state.live?.text && !state.live?.reasoning; |
| 133 | return ( |
| 134 | <> |
| 135 | {!transitioning && !props.imDetail && !noticePreview && <SessionRecoveryBanner key={transcript.tabId} |
| 136 | availability={transcript.availability} onRetry={props.onRetryHistory} />} |
| 137 | <main className="main"> |
| 138 | {props.imDetail && !transitioning ? ( |
| 139 | <SidebarImConnectionDetail |
| 140 | connection={props.imDetail.connection} |
| 141 | onClose={props.imDetail.onClose} |
| 142 | onOpenSettings={props.imDetail.onOpenSettings} |
| 143 | onManageAllowlist={() => props.imDetail!.onManageAllowlist(props.imDetail!.connection.connectionId)} |
| 144 | onOpenSession={() => props.imDetail!.onOpenSession(props.imDetail!.connection)} |
| 145 | /> |
| 146 | ) : noticePreview ? ( |
| 147 | <NoticePreviewPanel /> |
| 148 | ) : ( |
| 149 | <> |
| 150 | <div className="transcript-navigation-surface" aria-busy={transitioning}> |
| 151 | {props.launcher} |
| 152 | <div |
| 153 | className="transcript-navigation-content" |
| 154 | aria-hidden={transitioning || undefined} |
| 155 | ref={(node) => { |
| 156 | if (!node) return; |
| 157 | (node as HTMLElement & { inert?: boolean }).inert = transitioning; |
| 158 | }} |
| 159 | > |
| 160 | {recoveringEmpty ? <SessionRecoveryPlaceholder availability={transcript.availability} /> : <Transcript |
| 161 | items={transcript.items} |
| 162 | localSubmissions={localSubmissions} |
| 163 | localSubmissionSendRevision={state.localSubmissionSendRevision} |
| 164 | visibleSubmissionHandoffs={state.visibleSubmissionHandoffs} |
| 165 | live={transitioning ? undefined : state.live} |
| 166 | liveStore={transcript.liveStore} |
| 167 | tabId={transcript.tabId} |
| 168 | geometrySessionKey={transcript.geometrySessionKey} |
| 169 | footerHeight={transcript.footerHeight} |
| 170 | onPrompt={commands.onPrompt} |
| 171 | onFork={commands.onFork} |
| 172 | forkTargets={state.forkTargets} |
| 173 | forkBlocked={forkBlocked} |
| 174 | running={state.running || rewind.committing} |
| 175 | turnStartAt={state.turnStartAt} |
| 176 | hydrating={transcript.transcriptHydrating || (transitioning && !transcript.navigationDataReady)} |
| 177 | hasOlderHistory={!transitioning && state.historyHasOlder && !rewind.stateActive} |
| 178 | hasNewerHistory={!transitioning && state.historyHasNewer && !rewind.stateActive} |
| 179 | historyStartTurn={state.historyStartTurn} |
| 180 | historyEndTurn={state.historyEndTurn} |
| 181 | totalTurns={state.historyTotalTurns} |
| 182 | loadingOlderHistory={state.historyOlderLoading} |
| 183 | olderHistoryError={state.historyOlderError} |
| 184 | loadingNewerHistory={state.historyNewerLoading} |
| 185 | newerHistoryError={state.historyNewerError} |
| 186 | onLoadOlderHistory={commands.onLoadOlderHistory} |
| 187 | onLoadNewerHistory={commands.onLoadNewerHistory} |
| 188 | invocationMetadata={transcript.invocationMetadata} |
| 189 | surfaceCommitToken={transcript.surfaceCommitToken} |
| 190 | onSurfacePaintReady={commands.onSurfacePaintReady} |
| 191 | />} |
| 192 | </div> |
| 193 | {transitioning ? ( |
| 194 | <div className="transcript-navigation-overlay" role="status" aria-live="polite"> |
| 195 | <span className="transcript-navigation-overlay__spinner" aria-hidden="true" /> |
| 196 | <span>{t("common.loading")}</span> |
| 197 | </div> |
| 198 | ) : null} |
| 199 | </div> |
| 200 | </> |
| 201 | )} |
| 202 | </main> |
| 203 | </> |
| 204 | ); |
| 205 | } |
| 206 |