import { type VisualAgentStatusPayload, type VisualAuthority, type VisualBrowserInput, type VisualChangesetCommitPayload, type VisualViewOperation, type VisualChoicePresentPayload, type VisualDiagnostic, type VisualHandoffSummary, type VisualLayoutSavePayload, type VisualQuestionDelegatePayload, type VisualLayoutSaveResultPayload, type VisualViewSummary } from "../adapters/visual/protocol-contract.js"; import type { YarramateOperation } from "../operations.js"; import type { NestingKind } from "../nesting.js"; import type { ProjectionExclusion, ProjectionQuery } from "../projection.js"; import type { VisualRenderedModel, VisualServerFrame, VisualSessionSnapshot, VisualTranscriptRecord } from "../adapters/visual/wire.js"; /** * Everything the browser knows about a visual session, and the only place it * is decided. * * The reducer is pure and total: every frame the server can send, and every * intent the reviewer can express, becomes one action, and nothing else in the * application may change what is on screen. Transcript records are plain text * and are rendered through React text nodes, never as markup. */ /** * Who asked for the filter that is standing. * * `view` is a named view being applied; `editor` is that view's own query * being edited in the canvas panel, which is still THAT view and so leaves its * name standing; `panel` is an ad-hoc query belonging to no view; `chat` is the * agent's. */ /** * `focus` is a fifth source rather than a reuse of `panel` (#407). Both * narrow without a named view behind them, so both fall the same side of the * only test anyone makes of this field — whether the view name still stands — * but "who asked" is the question the field answers, and a context menu is * not the filter panel. */ export type FilterSource = "view" | "editor" | "panel" | "chat" | "focus"; /** The standing filter: the query, what it matched, and who asked. */ export interface ActiveFilter { readonly query: ProjectionQuery; readonly matchedIds: readonly string[]; /** * What the query dropped and why, or `null` when nobody reported it. * * A `filter-result` always carries the exclusions; a chat turn's * `appliedQuery` cannot - it is a schema-bound document requiring exactly * `query` and `matchedIds` (ADR 0090), and widening it would be a protocol * change to answer a question chat never asked. So `null` means unknown, * and the editor says nothing rather than reporting that a query dropped * nothing. */ readonly excluded: readonly ProjectionExclusion[] | null; readonly source: FilterSource; } /** How long after losing the socket the server still holds this session. */ export declare const RECONNECT_WINDOW_MS: number; /** Shown the moment End is requested, and kept in the record. */ export declare const VISUAL_END_NOTICE = "Returning control to the main agent"; export type VisualAppLifecycle = "connecting" | "active" | "ending" | "disconnected" | "closed"; /** * The session speaks too: it is the browser, not the agent, that tells the * reviewer control is going back. */ export type VisualAppSpeaker = VisualTranscriptRecord["speaker"] | "session"; export interface VisualAppRecord extends Omit { readonly speaker: VisualAppSpeaker; } /** The part of the server snapshot that decides what the browser renders. */ export interface VisualAppSnapshot { readonly authority: VisualAuthority; readonly title: string; readonly description: string; readonly chatEnabled: boolean; readonly model: VisualRenderedModel; readonly transcript: readonly VisualTranscriptRecord[]; readonly agentTurnOpen: boolean; readonly choices: VisualChoicePresentPayload | null; readonly styleNonce: string; readonly lastSequence: number; readonly frozen: boolean; readonly views: readonly VisualViewSummary[]; } export interface VisualAppState { readonly lifecycle: VisualAppLifecycle; readonly authority: VisualAuthority; readonly title: string; readonly description: string; readonly chatEnabled: boolean; /** Last model that compiled. A failed candidate never replaces it. */ readonly model: VisualRenderedModel | null; readonly styleNonce: string; readonly activeView: string; readonly transcript: readonly VisualAppRecord[]; readonly views: readonly VisualViewSummary[]; readonly choices: VisualChoicePresentPayload | null; readonly agentStatus: VisualAgentStatusPayload | null; readonly diagnostics: readonly VisualDiagnostic[]; readonly handoff: VisualHandoffSummary | null; readonly composerEnabled: boolean; readonly awaitingAgent: boolean; /** Records this browser has written itself, so a key is never reused. */ readonly localRecords: number; readonly lastSequence: number; readonly frozen: boolean; /** The last query the reviewer applied, and what it matched. `null` = unfiltered. */ readonly activeFilter: ActiveFilter | null; /** * What the canvas was showing when a focus narrowed it, so clearing the * focus returns there instead of to everything (#407). * * This is one level, not a history stack. Focusing again while already * focused keeps the original anchor, so "back" always means "back to what I * was working in" rather than back one step through a trail nobody was * keeping. Navigating, or any other filter, is a deliberate move away and * drops the anchor. */ readonly focusReturn: { readonly activeView: string; readonly activeFilter: ActiveFilter | null; } | null; /** Client-side substring narrowing layered on top of `activeFilter`. */ readonly quickFilterText: string; readonly closedReason: string | null; /** * What the host said when it ended the session for good, or null for the * shell's own sentence (#545). A hosted workspace refusing a ninth window, * a member removed while connected, a deleted workspace: the reader sees * the reason, not a fixed line about an agent that is not there. */ readonly closedMessage: string | null; /** * Operations staged for commit; replaces on same-field re-edit. Typed as the * commit payload itself, so the tray holds exactly what the wire takes and * `sourceDigests` cannot drift from the rows it vouches for: each row pins the * digest of the document it targets as it is staged, and the pin is never * refreshed while the row is staged - a pin that followed the newest model * frame would agree with disk and let the overwrite through (ADR 0093). */ readonly pendingChangeset: VisualChangesetCommitPayload; /** Prior `pendingChangeset` values, oldest first, for ordered undo. Whole * snapshots rather than inverse operations: staging replaces on the same * `(target, field)` key, so a re-edit destroys the value an inverse * operation would need to restore. The pins travel inside the snapshot, so an * undone row is restored still vouching for what it was staged against. Local * only - never on the wire, never persisted, and cleared once a batch lands, * because a landed batch is reverted with `git revert`, not from the browser. */ readonly undoStack: readonly VisualChangesetCommitPayload[]; /** Values taken off `undoStack`, most recently undone last. Any fresh * staging, discard or clear drops it: the reviewer took a new branch. */ readonly redoStack: readonly VisualChangesetCommitPayload[]; /** Idle when no commit in flight; committing while waiting for apply-result. */ readonly commitStatus: "idle" | "committing"; /** Diagnostics from the most recent failed commit; null when idle or on success. */ readonly commitDiagnostics: readonly VisualDiagnostic[] | null; /** The document list the server reported for the last successful commit - * never an optimistic local guess. Cleared when a fresh edit is staged. */ readonly commitNotice: readonly string[] | null; /** Transient notice from layout.save success/failure, cleared on next save. */ readonly layoutNotice: string | null; } export type VisualAppAction = { readonly type: "session.loaded"; readonly snapshot: VisualAppSnapshot; } | { readonly type: "model.received"; readonly model: VisualRenderedModel; /** Recounted against the graph that came with them. */ readonly views: readonly VisualViewSummary[]; } | { readonly type: "diagnostic.received"; readonly diagnostics: readonly VisualDiagnostic[]; } | { readonly type: "chat.sent"; readonly text: string; } | { readonly type: "chat.received"; readonly id: string; readonly text: string; } | { readonly type: "status.received"; readonly status: VisualAgentStatusPayload; } | { readonly type: "choice.presented"; readonly choice: VisualChoicePresentPayload; } | { readonly type: "choice.sent"; readonly optionId: string; } | { readonly type: "view.navigated"; readonly viewId: string; } | { readonly type: "filter.applied"; readonly query: ProjectionQuery; readonly matchedIds: readonly string[]; /** `null` where the answer did not carry them, never `[]`. */ readonly excluded: readonly ProjectionExclusion[] | null; readonly source: FilterSource; } | { readonly type: "filter.cleared"; } | { readonly type: "quickFilter.changed"; readonly text: string; } | { readonly type: "changeset.staged"; readonly operation: YarramateOperation; } | { readonly type: "changeset.viewStaged"; readonly operation: VisualViewOperation; } /** * One subject into or out of one view's own membership list. * * Not a `write-view` composed by the caller, because a second membership * edit has to be composed on top of the first: rows replace by path, so a * caller that composed from the SAVED document would silently drop the * subject it staged a moment ago. The reducer holds both the saved views and * the pending rows, so composing here is the only place it cannot be * forgotten. */ | { readonly type: "changeset.viewMembership"; readonly viewId: string; readonly subjectId: string; readonly membership: "add" | "remove"; } | { readonly type: "changeset.discarded"; readonly index: number; } | { readonly type: "changeset.cleared"; } | { readonly type: "changeset.undone"; } | { readonly type: "changeset.redone"; } | { readonly type: "changeset.commit.sent"; } | { readonly type: "changeset.committed"; readonly documents: readonly string[]; } | { readonly type: "apply.failed"; readonly diagnostics: readonly VisualDiagnostic[]; } | { readonly type: "layout.saved"; readonly result: VisualLayoutSaveResultPayload; } | { readonly type: "handoff.received"; readonly id: string; readonly handoff: VisualHandoffSummary; } | { readonly type: "event.acknowledged"; readonly sequence: number; } | { readonly type: "input.refused"; /** Which input the server refused, when the frame was named enough to * say. Absent only for a frame that parsed as no known input at all. */ readonly refused?: VisualBrowserInput["type"]; readonly diagnostics: readonly VisualDiagnostic[]; readonly frozen: boolean; } | { readonly type: "end.requested"; } | { readonly type: "connection.lost"; } | { readonly type: "session.closed"; readonly reason: string; /** The host's own sentence, when the closing frame carried one (#545). */ readonly message?: string; }; /** * A changeset with nothing in it. Named rather than repeated, because the day * the payload grows a third list is the day three literals silently disagree. */ export declare const EMPTY_CHANGESET: VisualChangesetCommitPayload; /** * Whether a changeset would land anything. Both lists count: a changeset * holding only a staged view is not an empty one, and every control that reads * "is there anything to commit" has to agree about that. */ export declare const changesetIsEmpty: (changeset: VisualChangesetCommitPayload) => boolean; export declare const initialVisualAppState: VisualAppState; export declare const visualAppSnapshotFrom: (snapshot: VisualSessionSnapshot) => VisualAppSnapshot; /** * A dropped socket is recoverable only while the server still holds the * session. Past the grace it has already recovered the handoff, so retrying * would only spend the reviewer's attention on a session that no longer exists. */ export declare const canReconnect: (lostAt: number, now: number) => boolean; export declare function visualAppReducer(state: VisualAppState, action: VisualAppAction): VisualAppState; /** Everything the reviewer can ask the session to do. */ export type VisualAppIntent = { readonly kind: "chat"; readonly text: string; } | { readonly kind: "choice"; readonly choiceId: string; readonly optionId: string; } | { readonly kind: "navigate"; readonly viewId: string; } | { readonly kind: "end"; } | { readonly kind: "filter"; readonly query: ProjectionQuery; readonly nesting?: readonly NestingKind[]; readonly showResponsibility?: boolean; } | { readonly kind: "commit-changeset"; } | { readonly kind: "save-layout"; readonly payload: VisualLayoutSavePayload; } | { readonly kind: "delegate"; readonly payload: VisualQuestionDelegatePayload; }; /** * One intent as the frame the server admits. Every frame carries the sequence * this browser last saw acknowledged, so a session that reconnected mid-turn * cannot slip a frame in behind a journal it never read. */ export declare const visualBrowserInputFor: (intent: VisualAppIntent, state: VisualAppState) => VisualBrowserInput; /** * The filter to ask again once a frame has been applied, or `null`. * * A filter is resolved against the model the server held when it was asked, * and a landed commit replaces that model. Nothing re-asks on its own, so * `matchedIds` goes on describing the graph as it was: a subject the reviewer * just created is not in it, and the canvas hides every element the matched * set does not name. The commit reports success and the diagram does not * change. That needs no unusual view and no stale projection - only a filter * that was resolved once, which is every view a session opens on. * * This is a consequence of a frame arriving rather than of a render, which is * why it lives here beside `visualAppActionsForFrame` and not in an effect: * the same `model` frame that invalidates the matched set is the thing that * has to ask for a new one. * * Only a `model` frame qualifies. A `filter-result` is the answer to this * question and must never re-ask it, or a session would ask forever. * * The query re-asked is whichever one is standing, under the source that asked * for it: a reviewer holding a panel filter must not have the active view's * query put back underneath them, and a chat-issued filter must not start * reporting itself as the reviewer's own. */ /** * How the active view can be told what it holds, as the menus must read it. * * `null` only where there is no view at all. A view that ENUMERATES its * subjects is told by editing that list; a view that describes them with * FACETS is told by editing `exclude`, the exception a rule cannot state * (#267, ADR 0122). Both are membership; they differ in which field moves and * in which direction, which is why the shape is a union rather than a list * with a flag. * * Read through the PENDING row when one is staged, not off the saved document. * A reviewer who has just added a subject and right-clicks it again must be * offered "Remove from view"; a menu built from the saved list would offer * "Add to this view" a second time and stage nothing. */ export type ActiveViewMembership = { readonly kind: "enumerated"; readonly subjects: readonly string[]; } | { readonly kind: "faceted"; readonly excluded: readonly string[]; }; /** * Where clearing a focus will return to, named for a label, or `undefined` * when clearing goes to everything (#407). * * Derived rather than stored, so the label cannot drift from the anchor. A * view the tree no longer lists falls back to `undefined` and the affordance * says "Show all" rather than naming something the reviewer cannot get to. */ export declare const focusReturnLabelOf: (state: VisualAppState) => string | undefined; export declare const activeViewMembership: (state: VisualAppState) => ActiveViewMembership | null; export declare const filterToReresolve: (frame: VisualServerFrame, state: VisualAppState) => { readonly query: ProjectionQuery; readonly source: FilterSource; } | null; /** * One server frame as the actions it means. Translation is pure so the socket * owns nothing but the socket. * * A `filter-result` says what matched, never why it was asked. Only the * browser knows whether it sent that query because the reviewer picked a * named view or edited the filter panel, so the caller reports the origin * it recorded when it asked. */ export declare const visualAppActionsForFrame: (frame: VisualServerFrame, filterOrigin?: FilterSource) => readonly VisualAppAction[];