dsh-diff-approval 0.29.0 → 0.30.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.
@@ -5,9 +5,30 @@
5
5
  * @module dsh-diff-approval/client/port
6
6
  */
7
7
  import type { ClientConnectionRpc, SessionId } from '@deepseek-ai/dsh-client-connection/client';
8
- import type { DiffApprovalActionValue, DiffApprovalAddValue, DiffApprovalBlockRange, DiffApprovalBrowseValue, DiffApprovalBulkValue, DiffApprovalListValue, DiffApprovalOpenAction, DiffApprovalOpenValue, DiffApprovalPreviewImageValue, DiffApprovalRefreshValue, VcsImportValue } from '../types.ts';
8
+ import type { CommentAnchor, CommentQuoteLine, DiffApprovalActionValue, DiffApprovalAddValue, DiffApprovalBlockRange, DiffApprovalBrowseValue, DiffApprovalBulkValue, DiffApprovalCommentAddValue, DiffApprovalCommentAskValue, DiffApprovalCommentRemoveManyValue, DiffApprovalCommentRemoveValue, DiffApprovalListValue, DiffApprovalOpenAction, DiffApprovalOpenValue, DiffApprovalPreviewImageValue, DiffApprovalRefreshValue, VcsImportValue } from '../types.ts';
9
9
  /** The channel the host half registers and this port calls. */
10
10
  export declare const DIFF_APPROVAL_CHANNEL = "/diff-approval";
11
+ /**
12
+ * One annotation as the panel hands it over: what the reader wrote and where they
13
+ * wrote it. The display path is not part of it — the entry a comment hangs off is the
14
+ * host's authority on what the file is called.
15
+ */
16
+ export interface CommentDraft {
17
+ /** Caller-minted id, so a retried write lands on the same comment. */
18
+ id: string;
19
+ /** The listed entry the annotation hangs off. */
20
+ entryId: string;
21
+ /** The lines it was made on, as new-file line numbers. */
22
+ anchor: CommentAnchor;
23
+ /** The anchored lines as they read then (the re-anchor fingerprint). */
24
+ quote: string;
25
+ /** `quote` with one line of context on each side, as it read then. */
26
+ quoteContext?: string | undefined;
27
+ /** The gutter numbers of `quote`'s lines, in the same order. */
28
+ quoteLines?: CommentQuoteLine[] | undefined;
29
+ /** What the reader wrote. */
30
+ text: string;
31
+ }
11
32
  /** This package's business verbs over the review channel. */
12
33
  export interface DiffApprovalPort {
13
34
  /** Read one session's pending entries (plus its workspace root), oldest capture first. */
@@ -41,8 +62,36 @@ export interface DiffApprovalPort {
41
62
  keepAll(sessionId: SessionId): Promise<DiffApprovalBulkValue>;
42
63
  /** Revert every pending entry of one session in a single host call (one batch). */
43
64
  revertAll(sessionId: SessionId): Promise<DiffApprovalBulkValue>;
65
+ /**
66
+ * Keep a PICK of entries in one host call: one request, one durable write and one undo step — the
67
+ * host records the whole pick as a single batch, so one Ctrl+Z takes that one decision back instead
68
+ * of peeling it apart file by file. `keepListed` says whether the resolved files stay listed.
69
+ *
70
+ * There is a `revertMany` beside it, and the pair is NOT symmetric: a keep writes nothing, while a
71
+ * revert writes the baseline over each file — and DELETES the ones the agent created, which the host
72
+ * cannot undo. The panel asks before sending a pick whose revert would delete anything.
73
+ */
74
+ keepMany(sessionId: SessionId, ids: readonly string[], keepListed: boolean | undefined): Promise<DiffApprovalBulkValue>;
75
+ /** Revert a PICK of entries in one host call: the same one request, one write, one undo step. The undo
76
+ * restores the entries the host could snapshot — see `keepMany` for the deletes it cannot. */
77
+ revertMany(sessionId: SessionId, ids: readonly string[], keepListed: boolean | undefined): Promise<DiffApprovalBulkValue>;
44
78
  /** Read one workspace image and inline it as a base64 data URI (for the Markdown preview). */
45
79
  previewImage(sessionId: SessionId, path: string): Promise<DiffApprovalPreviewImageValue>;
80
+ /**
81
+ * Write one annotation down. The host refuses a comment whose entry is not in the
82
+ * session's list, and the refusal is reported rather than retried.
83
+ */
84
+ commentAdd(sessionId: SessionId, comment: CommentDraft): Promise<DiffApprovalCommentAddValue>;
85
+ /** Drop one annotation (what "a comment dies with its entry" does by hand). */
86
+ commentRemove(sessionId: SessionId, id: string): Promise<DiffApprovalCommentRemoveValue>;
87
+ /**
88
+ * Drop several annotations in one host call: one request, one write of the session's comment file,
89
+ * and one read afterwards (`store.commentRemoveMany`). A batch that is a loop of `commentRemove`
90
+ * calls is a different thing — N requests, N writes — which is what this endpoint exists to avoid.
91
+ */
92
+ commentRemoveMany(sessionId: SessionId, ids: readonly string[]): Promise<DiffApprovalCommentRemoveManyValue>;
93
+ /** Ask one stored comment as its own turn of the session. */
94
+ commentAsk(sessionId: SessionId, id: string, prompt: string, text: string): Promise<DiffApprovalCommentAskValue>;
46
95
  }
47
96
  /** Build the port over one generic RPC caller.
48
97
  * @param rpc - the connection's channel caller.
@@ -13,7 +13,7 @@
13
13
  * @module dsh-diff-approval/client/presentation-menu
14
14
  */
15
15
  import type { ReactNode } from 'react';
16
- import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives';
16
+ import type { MenuEntry } from './dsh-icons.ts';
17
17
  import type { DiffApprovalPresentation } from './settings.ts';
18
18
  import type { Translator } from './locales.ts';
19
19
  /**
@@ -1,27 +1,64 @@
1
1
  /**
2
- * Inject a "查看差异" button beside each DSH produced-file chip.
2
+ * Route a file press's own click.
3
3
  *
4
- * The harness's `ProducedFiles` component renders each produced file as a
5
- * `<button>` chip inside `[data-produced-files-row]`; the full path rides the
6
- * chip's `title`. This plugin wants a quick "view this file's diff" affordance
7
- * there, but the plugin should not have to touch the harness component. So this
8
- * module watches the DOM for those chips (via a MutationObserver, the same
9
- * bridge the dsh-pocket mobile fork uses to inject its 复制 buttons) and injects
10
- * a small button after each one. Clicking it dispatches a window event that the
11
- * diff-approval panel listens for (see PendingPanel), which opens the panel and
12
- * selects the file when it is still pending, or toasts otherwise.
4
+ * The harness draws several presses that answer by opening a file in a viewer of its OWN, and this
5
+ * module takes over exactly those — and only for a file the review panel is already holding — so the
6
+ * reader gets both ways of opening it instead of one fixed behaviour:
13
7
  *
14
- * The injected button is intentionally self-contained (inline styles + a fixed
15
- * label) so it needs no stylesheet and no harness change.
8
+ * `[data-produced-files-row] button` 0.1.5's produced-file card `title` = the path
9
+ * `[data-presented-files-row] button` 0.1.7's presented-file card `title` = the path
10
+ * `[data-changed-files] button` 0.1.7's changed-files row `aria-describedby` = the path
11
+ * `button[title]` a message's file LINK (0.1.7) `title` = the path
12
+ * `button[data-ref-chip]` a `@file` chip in a message `title` = the token
13
+ * `a[href]` any other link naming a file `href` = the path
14
+ *
15
+ * Everything else is left entirely alone: a link to a URL, an in-page anchor, a chip naming something
16
+ * that is not a file, a file this panel does not hold, a modified press (Ctrl/Cmd/Shift/Alt, or a
17
+ * middle click — gestures the harness may give its own meaning later), and any press inside this
18
+ * plugin's own surface, whose buttons carry paths too and belong to the panel that is already showing
19
+ * the file.
20
+ *
21
+ * The name of the module (and of the event it raises) dates from when the produced-file card was the
22
+ * only press of this kind; `data-presented-files-row` is that same list under 0.1.7's name for it.
16
23
  */
17
- /** Window event dispatched by the injected button; PendingPanel listens for it. */
24
+ /** Window event dispatched when a pending file's press is taken over; the panel listens for it. */
25
+ export declare const CHIP_MENU_EVENT = "diff-approval:chip-menu";
26
+ /** Window event dispatched by the panel to open a file in the panel; PendingPanel listens for it. */
18
27
  export declare const OPEN_FILE_EVENT = "diff-approval:open-file";
28
+ /** What a press tells the panel: which file, and where the press was (the menu hangs under it). */
29
+ export interface ProducedChipMenuDetail {
30
+ /** The path the pressed element names. */
31
+ path: string;
32
+ /** The pressed element's own box, in viewport coordinates. */
33
+ x: number;
34
+ y: number;
35
+ }
36
+ /** What the bridge needs from the host to decide, and to report. */
37
+ export interface ProducedChipBridge {
38
+ /**
39
+ * Whether the panel holds this file right now. Called synchronously on every press, because
40
+ * the decision it answers is whether the press is prevented — there is no second chance at it.
41
+ */
42
+ isPending: (path: string) => boolean;
43
+ /** A pending file's press was taken over: open the menu the press asked for. */
44
+ onMenu: (detail: ProducedChipMenuDetail) => void;
45
+ }
19
46
  /**
20
- * Start injecting diff buttons into the produced-files row.
21
- * @param label - localized "查看差异" text, used as the icon button's accessible
22
- * name + hover title (the button renders only an icon).
23
- * @param openPath - called with a produced-file path when its injected button is
24
- * clicked; the diff-approval panel decides whether that file is still pending.
25
- * @returns a cleanup that disconnects the observer and removes injected buttons.
47
+ * Press a file's own press again — the shell's open, run the only way this plugin can run it
48
+ * faithfully, with the bridge standing down for that press (see the file's own doc).
49
+ *
50
+ * The menu's first row must open the file exactly the way the shell does, and what that is is the
51
+ * shell's business, not this plugin's: pressing the same element again keeps every part of it —
52
+ * the viewer it opens, the line a `#L24` suffix asked for, whatever a future shell adds there.
53
+ *
54
+ * @param path - the file the press names.
55
+ * @returns whether an element naming it was found to press.
56
+ */
57
+ export declare function replayFilePress(path: string): boolean;
58
+ /**
59
+ * Start routing the presses that would open a file in the shell's own viewer.
60
+ *
61
+ * @param bridge - the host's decision (is the file pending) and where to report a press that is.
62
+ * @returns a cleanup that stops routing.
26
63
  */
27
- export declare function startProducedDiffInjection(label: string, openPath: (path: string) => void): () => void;
64
+ export declare function startProducedChipMenu(bridge: ProducedChipBridge): () => void;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Which session the review is about, as every shell of this plugin has offered it.
3
+ *
4
+ * 0.1.7 changed how a client learns the selected session. Its session-list store carries
5
+ * `{ ids, byId, phase, projectionsBySession }` — there is no `current` any more — and the shell's own
6
+ * components take the id from their composed SLOT PROPS instead (`const { sessionId, useSessions } =
7
+ * props`, then `useSessions(s => s.byId[sessionId]?.blank)`; measured in `dsh-client-ui-conversation`
8
+ * and `dsh-client-ui-agent-preset`). Older shells put it in the store as `state.current`.
9
+ *
10
+ * The seats differ, and that is the whole reason this module exists: the Session HEADER seat is
11
+ * session-scoped and is handed `sessionId`; the sidebar FOOTER seat — where this panel's own badge
12
+ * lives — is rendered `renderSlot('sidebar.footer.action', { wide })` and is handed nothing else. So
13
+ * the header publishes what it is showing, the footer reads it, and the store is the third fallback
14
+ * (`current` on the shells that have it). Every reader here is total: an unknown shape answers
15
+ * "nothing known" rather than throwing, and no field that is absent is taken for a value.
16
+ *
17
+ * @module dsh-diff-approval/client/session-seat
18
+ */
19
+ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client';
20
+ /**
21
+ * Publish the session the header entry is showing.
22
+ * @param id - the id, or `undefined` when the shell named none.
23
+ */
24
+ export declare function publishSessionId(id: SessionId | undefined): void;
25
+ /** The session the header entry last showed, or `undefined` when it never named one. */
26
+ export declare function publishedSessionId(): SessionId | undefined;
27
+ /**
28
+ * The selected session a shell's session-list state names, by every field the shells have used.
29
+ * @param state - the store state handed to the selector.
30
+ * @returns the id, or `undefined` when this shape names none.
31
+ */
32
+ export declare function selectedSessionOf(state: unknown): SessionId | undefined;
33
+ /**
34
+ * Whether one session row is BLANK — a freshly created session with nothing in it yet, which has
35
+ * nothing to review and leaves this plugin's entry inert exactly like no session at all.
36
+ *
37
+ * `byId[id].blank` is the row's own flag and has kept its name across the shells; a store that
38
+ * carries `blank` on the STATE (with the id beside it) is read too. An absent flag is not blank: a
39
+ * shape this plugin does not recognise must not disable the panel.
40
+ *
41
+ * @param state - the store state handed to the selector.
42
+ * @param id - the session the answer is about; `undefined` answers false.
43
+ * @returns true only when the shell says that session is blank.
44
+ */
45
+ export declare function sessionIsBlank(state: unknown, id: SessionId | undefined): boolean;
46
+ /**
47
+ * The session this mount is about: the slot prop the shell composed, else the store's own selected
48
+ * session, else what the header entry published (see the module doc).
49
+ * @param propSessionId - `sessionId` from the composed slot props, when the shell sends one.
50
+ * @param storeSelected - the store's selected session, when its shape names one.
51
+ * @returns the id, or `undefined` when nothing here knows one.
52
+ */
53
+ export declare function sessionIdFor(propSessionId: SessionId | undefined, storeSelected: SessionId | undefined): SessionId | undefined;
@@ -2,14 +2,34 @@
2
2
  import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots';
3
3
  import type { SessionId } from '@deepseek-ai/dsh-client-connection/client';
4
4
  import type { DockSnapshot } from './dock.tsx';
5
- import type { ChatView } from './chat-bridge.ts';
6
- import type { DiffApprovalAddValue, DiffApprovalBlockRange, DiffApprovalBrowseValue, DiffApprovalOpenAction, DiffApprovalRefreshValue, PendingFileDiff, VcsImportValue } from '../types.ts';
5
+ import type { CommentDraft } from './port.ts';
6
+ import type { CommentLineRange } from '../comment-lines.ts';
7
+ import type { CommentRecord, DiffApprovalAddValue, DiffApprovalBlockRange, DiffApprovalBrowseValue, DiffApprovalCommentAddValue, DiffApprovalCommentAskValue, DiffApprovalCommentRemoveManyValue, DiffApprovalCommentRemoveValue, DiffApprovalOpenAction, DiffApprovalRefreshValue, PendingFileDiff, VcsImportValue } from '../types.ts';
7
8
  /** What the panel reads and drives: the pending list plus in-flight entries. */
8
9
  export interface PendingDiffSnapshot {
9
10
  /** Whether a list read has completed at least once. */
10
11
  read: boolean;
11
12
  /** Pending entries, one per file, oldest capture first. */
12
13
  files: PendingFileDiff[];
14
+ /**
15
+ * The session's comment threads, oldest first. They arrive on the same read as the
16
+ * entries, from the host — the one copy every client of this session shares, so two
17
+ * browsers show the same threads instead of two private ones.
18
+ */
19
+ comments: CommentRecord[];
20
+ /**
21
+ * The new-file lines each comment sits on NOW, keyed by comment id, as the HOST resolved them
22
+ * against the entry's current content (see the wire type). One map for the whole panel: the list
23
+ * pane's label, the card's own chip and the jump all draw it, so a comment cannot read one line in
24
+ * the list and another in the code view — and a file nobody has opened is named just as rightly,
25
+ * which is what the reader asked for. A comment the host could not place is absent, and its
26
+ * callers fall back to `record.anchor`.
27
+ */
28
+ commentLines: Readonly<Record<string, CommentLineRange>>;
29
+ /** The host's comment revision counter, bumped on every comment change. */
30
+ commentsRevision: number;
31
+ /** Derived answer text per comment id, read by the host from the session's transcript. */
32
+ commentAnswers: Record<string, string>;
13
33
  /** A read failure's message; absent while the latest read succeeded. */
14
34
  error?: string;
15
35
  /** Entry ids whose keep/revert is in flight; their controls are disabled. */
@@ -30,6 +50,20 @@ export interface PendingDiffSnapshot {
30
50
  * told finds the list gone later with nothing to explain it (see the wire type).
31
51
  */
32
52
  persistError?: string | undefined;
53
+ /**
54
+ * The last failure to write the session's COMMENT files, or absent while those writes are
55
+ * working. Their own field (and their own copy) because they are their own files: a comment
56
+ * that only exists in the host's memory is erased by a restart exactly like an unpersisted
57
+ * list, and the panel says so once — the same way, and for the same reason, as `persistError`.
58
+ */
59
+ commentPersistError?: string | undefined;
60
+ /**
61
+ * Why the last undo/redo was refused by the host, or absent when there is nothing to say.
62
+ * The host answers a refused undo with its reason (the divergence guard's "the file changed
63
+ * outside the review"), and the reader who pressed the key has to be told: the panel surfaces
64
+ * this once as a toast and clears it, so the next refusal is news again.
65
+ */
66
+ undoNotice?: string | undefined;
33
67
  /** Latched when an external change created a fresh undo checkpoint that
34
68
  * superseded the redo history; the panel surfaces it once (deferred if the
35
69
  * panel is closed) via a bottom-right notice. */
@@ -75,15 +109,6 @@ export interface PendingPanelFace {
75
109
  onPreviewImage: (sessionId: SessionId, path: string) => Promise<string | undefined>;
76
110
  /** Paste a copied reference into the session's chat input and focus it. */
77
111
  onPasteReference: (sessionId: SessionId, reference: string) => void;
78
- /**
79
- * Send one prompt into the session as a real turn (the discussion's answer
80
- * comes back through `watchChat`).
81
- * @returns whether a send verb was available; false means the caller should
82
- * fall back to the composer route.
83
- */
84
- onAskAgent: (sessionId: SessionId, text: string) => boolean;
85
- /** Watch a session's transcript and turn state for a discussion's answer. */
86
- watchChat: (sessionId: SessionId, listener: (view: ChatView) => void) => () => void;
87
112
  /** Undo the session's last keep/revert, then refresh the list; resolves to the affected entry id when it is still pending. */
88
113
  onUndo: (sessionId: SessionId) => Promise<string | undefined>;
89
114
  /** Redo the session's last undone keep/revert, then refresh the list; resolves to the affected entry id when it is still pending. */
@@ -101,8 +126,43 @@ export interface PendingPanelFace {
101
126
  onKeepAll: (sessionId: SessionId) => Promise<void>;
102
127
  /** Revert every pending entry of one session in a single host call (bulk). */
103
128
  onRevertAll: (sessionId: SessionId) => Promise<void>;
129
+ /**
130
+ * Keep a PICK of files in one host call. The pick is one decision the reader made, so it goes to the
131
+ * host as one request — one durable write, one read back, and one undo step — rather than a loop of
132
+ * per-file calls that undo would then peel apart.
133
+ */
134
+ onKeepMany: (sessionId: SessionId, ids: readonly string[], keepListed: boolean | undefined) => Promise<void>;
135
+ /**
136
+ * Revert a PICK of files in one host call; the same one request, one write, one undo step. Unlike a
137
+ * keep this writes over the files, and a file the agent created is DELETED with no undo — so the panel
138
+ * puts a confirmation in front of a pick whose revert would delete (see the pick's menu).
139
+ */
140
+ onRevertMany: (sessionId: SessionId, ids: readonly string[], keepListed: boolean | undefined) => Promise<void>;
141
+ /**
142
+ * Write one annotation down in the session's comment store. The host owns the
143
+ * record from here on, so the next read is what shows it (and every other client
144
+ * of this session sees it too).
145
+ */
146
+ onCommentAdd: (sessionId: SessionId, comment: CommentDraft) => Promise<DiffApprovalCommentAddValue>;
147
+ /** Drop one annotation from the session's comment store. */
148
+ onCommentRemove: (sessionId: SessionId, id: string) => Promise<DiffApprovalCommentRemoveValue>;
149
+ /**
150
+ * Drop several annotations in one host call. Ending a pick of comments is one action the reader
151
+ * asked for, so it goes to the host as one request and comes back as one read — see
152
+ * `DiffApprovalStore.commentRemoveMany`.
153
+ */
154
+ onCommentRemoveMany: (sessionId: SessionId, ids: readonly string[]) => Promise<DiffApprovalCommentRemoveManyValue>;
155
+ /**
156
+ * Ask one stored comment as its own turn of the session; the answer arrives on the
157
+ * next read, derived by the host from the transcript. `prompt` is what the agent is
158
+ * asked and `text` is the reader's own words inside it, which the host stores on the
159
+ * question so the thread can draw what was written.
160
+ */
161
+ onCommentAsk: (sessionId: SessionId, id: string, prompt: string, text: string) => Promise<DiffApprovalCommentAskValue>;
104
162
  /** Acknowledge the redo-cleared notice so it is only surfaced once. */
105
163
  onAckRedoCleared: () => void;
164
+ /** Acknowledge a refused-undo notice once the panel has said it (see `undoNotice`). */
165
+ onAckUndoNotice: () => void;
106
166
  /** Collapse the DSH sidebar (no-op when already collapsed) before the floating
107
167
  * modal opens on a narrow window, so the sidebar can't overlap it. */
108
168
  collapseSidebar: () => void;
@@ -7,9 +7,9 @@
7
7
  */
8
8
  import type { SessionId } from '@deepseek-ai/dsh-client-connection/client';
9
9
  import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots';
10
- import type { DiffApprovalAddValue, DiffApprovalBlockRange, DiffApprovalBrowseValue, DiffApprovalOpenAction, DiffApprovalRefreshValue, VcsImportValue } from '../types.ts';
10
+ import type { DiffApprovalAddValue, DiffApprovalBlockRange, DiffApprovalBrowseValue, DiffApprovalCommentAddValue, DiffApprovalCommentAskValue, DiffApprovalCommentRemoveManyValue, DiffApprovalCommentRemoveValue, DiffApprovalOpenAction, DiffApprovalRefreshValue, VcsImportValue } from '../types.ts';
11
11
  import type { PendingDiffSnapshot } from './slots.ts';
12
- import type { DiffApprovalPort } from './port.ts';
12
+ import type { CommentDraft, DiffApprovalPort } from './port.ts';
13
13
  /** The observable the panel reads and the plugin body drives. */
14
14
  export interface PendingDiffStore extends HostObservable<PendingDiffSnapshot> {
15
15
  /** Re-read one session's pending list (an absent session empties the view). */
@@ -41,12 +41,45 @@ export interface PendingDiffStore extends HostObservable<PendingDiffSnapshot> {
41
41
  keepAll: (sessionId: SessionId) => Promise<void>;
42
42
  /** Revert every pending entry of one session in a single host call, then refresh. */
43
43
  revertAll: (sessionId: SessionId) => Promise<void>;
44
+ /**
45
+ * Keep a pick of entries, then refresh once. The host makes the whole pick ONE undo step, so this is
46
+ * one action to the reader in every sense: one request, one read back, one Ctrl+Z.
47
+ */
48
+ keepMany: (sessionId: SessionId, ids: readonly string[], keepListed: boolean | undefined) => Promise<void>;
49
+ /**
50
+ * Revert a pick of entries, then refresh once. Its undo restores what the host could snapshot: a file
51
+ * the agent created is DELETED with no undo at all, so the panel asks before a pick that would do it.
52
+ */
53
+ revertMany: (sessionId: SessionId, ids: readonly string[], keepListed: boolean | undefined) => Promise<void>;
44
54
  /** Inline one workspace image as a base64 data URI (empty when unreadable). */
45
55
  previewImage: (sessionId: SessionId, path: string) => Promise<string | undefined>;
56
+ /**
57
+ * Write one annotation down, then refresh so the thread appears from the host's own
58
+ * copy (the panel keeps no thread state of its own).
59
+ */
60
+ commentAdd: (sessionId: SessionId, comment: CommentDraft) => Promise<DiffApprovalCommentAddValue>;
61
+ /** Drop one annotation, then refresh. */
62
+ commentRemove: (sessionId: SessionId, id: string) => Promise<DiffApprovalCommentRemoveValue>;
63
+ /**
64
+ * Drop several annotations in ONE host call, then refresh once.
65
+ *
66
+ * One refresh is the difference a batch makes here: a loop of `commentRemove` re-reads (and
67
+ * re-renders) the whole session between every comment, so the list visibly dismantles itself one
68
+ * item at a time while the reader asked for one action.
69
+ */
70
+ commentRemoveMany: (sessionId: SessionId, ids: readonly string[]) => Promise<DiffApprovalCommentRemoveManyValue>;
71
+ /** Ask one stored comment as its own turn, then refresh (the answer is derived on read). */
72
+ commentAsk: (sessionId: SessionId, id: string, prompt: string, text: string) => Promise<DiffApprovalCommentAskValue>;
46
73
  /** Drop every local fact (used on connection reset). */
47
74
  reset: () => void;
48
75
  /** Acknowledge a redo-cleared notice so the panel surfaces it only once. */
49
76
  clearRedoCleared: () => void;
77
+ /**
78
+ * Acknowledge the last refused-undo notice, once the panel has said it. Without this
79
+ * the field would sit there for the rest of the session and the same refusal later
80
+ * would not be news; the panel says it and clears it in one step.
81
+ */
82
+ clearUndoNotice: () => void;
50
83
  }
51
84
  /**
52
85
  * Create the store over the review-channel port.
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Asking the session's agent about one comment, and reading the answer back out of
3
+ * the transcript.
4
+ *
5
+ * The submission goes through the host's own session API (`ctx.sessionController`),
6
+ * which is the one path that already knows how to admit a prompt: it mints the
7
+ * message, persists it, and — the reason this module needs no identity of its own —
8
+ * stores the request id the caller supplies on the exact user message it accepts
9
+ * (`source.rpcId`). That id is the join key between a comment and its message, and it
10
+ * makes a retried submission the same submission rather than a second one.
11
+ *
12
+ * The answer is never stored: `answers()` derives it from the session's own derived
13
+ * message history on every read, so the transcript stays the single source of truth
14
+ * for what the agent said. The agent is asked with `mode: 'queue'`, so a comment is
15
+ * its own turn ("one ordinary message of its own turn") — two comments asked at the
16
+ * same time are two turns, not one turn two blocks have to share.
17
+ *
18
+ * @module dsh-diff-approval/src/comment-ask
19
+ */
20
+ import type { Context } from '@deepseek-ai/cordis';
21
+ import type { SessionId } from '@deepseek-ai/dsh-session';
22
+ import type { DiffApprovalCommentAskValue } from './types.ts';
23
+ import type { CommentStore } from './comments.ts';
24
+ /** One derived transcript message, narrowed to what answering a comment needs. */
25
+ interface DerivedMessage {
26
+ role?: unknown;
27
+ content?: unknown;
28
+ source?: {
29
+ kind?: unknown;
30
+ rpcId?: unknown;
31
+ } | undefined;
32
+ }
33
+ /**
34
+ * One entry of the session's event log, narrowed to what answering a comment reads.
35
+ *
36
+ * The log is where the turn numbers live: `deriveMessages` hands back messages with no
37
+ * attribution at all, and an answer belongs to the turn that claimed the question, not
38
+ * to "everything up to the next message a human happened to write".
39
+ */
40
+ interface LogEvent {
41
+ type?: unknown;
42
+ data?: LogData | undefined;
43
+ }
44
+ /** One event's payload, as the by-type reads below see it. */
45
+ interface LogData {
46
+ /** The turn a message or boundary belongs to (`turn/start`, `user/message`, …). */
47
+ turn?: unknown;
48
+ /** `assistant/message` carries its assembled message here rather than flat. */
49
+ message?: DerivedMessage | undefined;
50
+ /** `user/message` IS the message, so role/content/source sit on the payload. */
51
+ role?: unknown;
52
+ content?: unknown;
53
+ source?: {
54
+ kind?: unknown;
55
+ rpcId?: unknown;
56
+ } | undefined;
57
+ }
58
+ /**
59
+ * What one question's transcript read found.
60
+ *
61
+ * `ended` is the turn's own end, not a verdict about the answer: whether an answer exists
62
+ * is what `answer` says. Keeping the two apart is what lets the panel show a stopped note
63
+ * for a turn that ended with nothing while a turn that ended AFTER writing keeps showing
64
+ * its answer.
65
+ */
66
+ export interface AskRead {
67
+ /** The claiming turn's last assistant text, or `undefined` while it has written none. */
68
+ answer: string | undefined;
69
+ /**
70
+ * The turn that claimed the question, as the transcript itself attributes it. The inbox
71
+ * claim records the same number (`agent/inbox/claimed`); this is the read that does not
72
+ * depend on having been subscribed at the time.
73
+ */
74
+ turn: number | undefined;
75
+ /** The claiming turn is over (`turn/end` in the log), whatever it did or did not write. */
76
+ ended: boolean;
77
+ }
78
+ /**
79
+ * The answer to one submitted comment, read from the session's own event log.
80
+ *
81
+ * Our prompt and the turn that ran because of it are one thing. The submission carries a
82
+ * request id on its `user/message` (`source.rpcId`), and `turn/start` is the bracket the
83
+ * log puts around a turn, so the question's own turn IS the window: the assistant
84
+ * messages of that turn are the answer, and `turn/end` for it is where the question is
85
+ * over. Nothing between the two belongs to anyone else.
86
+ *
87
+ * That is what keeps a later input from stealing the answer. A session log has
88
+ * user-role messages no human wrote — a subagent report, a background-job notice, an
89
+ * injected context — and under the old "walk to the next human message, take the last
90
+ * assistant text" rule any of them let the span run on into the NEXT turn's answer, so a
91
+ * comment that had been answered was silently replaced by whatever the agent later said
92
+ * about something else entirely. A tool-result message (role `user` as well) has the
93
+ * same problem for the opposite reason: it is a boundary trap that would end a segment
94
+ * mid-turn and make a question with a tool call behind it look unanswered. Bound by the
95
+ * turn, both read correctly: the tool result is inside it, and the later input is not.
96
+ *
97
+ * @param events - the session's event log, in sequence order.
98
+ * @param requestId - the request id the comment was submitted under.
99
+ * @returns the claiming turn, its answer text, and whether the turn is over.
100
+ */
101
+ export declare function answerForRequest(events: readonly LogEvent[], requestId: string): AskRead;
102
+ /**
103
+ * Read every question one session has asked, keyed by its request id.
104
+ * @param events - the session's event log, in sequence order.
105
+ * @param requestIds - the questions' request ids.
106
+ * @returns the read per request id; a question the log holds nothing for reads as nothing.
107
+ */
108
+ export declare function answersIn(events: readonly LogEvent[], requestIds: readonly string[]): Record<string, AskRead>;
109
+ /**
110
+ * Submits comments as turns of their session, and reads their answers back.
111
+ *
112
+ * One instance serves every session: the store is the state, and the per-agent
113
+ * subscription is registered on first use and lives with the agent's own context.
114
+ */
115
+ export declare class CommentAsker {
116
+ private readonly ctx;
117
+ private readonly comments;
118
+ /**
119
+ * The agent each session's inbox events are subscribed on, keyed by session id, with
120
+ * the disposers that release those subscriptions.
121
+ *
122
+ * Keyed by AGENT IDENTITY, not merely "this session id was watched once": a
123
+ * resumed or re-created agent for the same session id is a different object
124
+ * whose listeners live on its OWN context, so a session-id set would consider
125
+ * the new agent already watched and never subscribe to it — leaving `turn`
126
+ * undefined forever, `agent/turn-stopping` never arriving, and the panel stuck
127
+ * on "replying" with no way out (the defect this map replaces). The disposers are
128
+ * what keeps a replacement from leaking a second set of handlers per event.
129
+ */
130
+ private readonly watched;
131
+ /**
132
+ * @param ctx - the plugin's host context (its `sessionController` and `agents` are asked for lazily).
133
+ * @param comments - the comment store the ask is recorded in.
134
+ */
135
+ constructor(ctx: Context, comments: CommentStore);
136
+ /**
137
+ * Ask one stored comment as its own turn.
138
+ *
139
+ * The request id is recorded BEFORE the submission leaves, so a claim event that
140
+ * arrives while the submission is still in flight already finds its comment. A
141
+ * submission that rejects is marked dropped rather than left looking pending.
142
+ *
143
+ * @param sessionId - the session the comment belongs to.
144
+ * @param commentId - the comment to ask.
145
+ * @param prompt - the prompt text to send verbatim.
146
+ * @param text - the reader's own words, which the prompt wraps: the thread keeps these.
147
+ * @param signal - aborts the submission.
148
+ * @returns what the request did.
149
+ */
150
+ ask(sessionId: SessionId, commentId: string, prompt: string, text: string, signal: AbortSignal): Promise<DiffApprovalCommentAskValue>;
151
+ /**
152
+ * Every question of one session and what the transcript says about it, keyed by the
153
+ * question's request id (which is what `CommentAsk` records and what a client matches
154
+ * against). Derived from the session's own event log on every call and never stored:
155
+ * the log is the one source of truth for what the agent said, so a stored copy could
156
+ * only ever disagree with it.
157
+ * @param sessionId - the session whose comments to read.
158
+ * @returns the read per question; empty when the session holds no questions.
159
+ */
160
+ answers(sessionId: SessionId): Record<string, AskRead>;
161
+ /**
162
+ * The turns the session's log shows as over, for the questions it holds.
163
+ *
164
+ * `agent/turn-stopping` already reports a turn's end live, and that is what usually
165
+ * writes `ended` down (see `markTurnEnded`). Reading it out of the log as well is what
166
+ * makes the flag survive a session this process did not watch end — a resumed session,
167
+ * a client that connected afterwards, a deployment whose agent events cannot be
168
+ * subscribed at all — because `turn/end` is durable and the event is not.
169
+ *
170
+ * @param sessionId - the session whose turns to check.
171
+ * @returns the turns the log closed, or empty when the log cannot be read.
172
+ */
173
+ endedTurns(sessionId: SessionId): number[];
174
+ /**
175
+ * One session's event log, as the reads above take it.
176
+ *
177
+ * `snapshotEvents()` and not `deriveMessages()`: the derived history is a list of
178
+ * messages with no turn attribution, and the turn is the whole point of the answer read
179
+ * (see `answerForRequest`). The event log is the session's append-only source of truth
180
+ * and `snapshotEvents` hands back a frozen, sequence-ordered copy of it, so walking it
181
+ * is a plain read that cannot disturb the session.
182
+ *
183
+ * @param sessionId - the session to read.
184
+ * @returns its events, or undefined when no live session can be read.
185
+ */
186
+ private sessionEvents;
187
+ /** The live agent for one session, or `undefined` when this host drives none. */
188
+ private agentOf;
189
+ /**
190
+ * Subscribe to one agent's inbox, once per LIVE agent, moving to a replacement agent
191
+ * when the session's agent changes under us.
192
+ *
193
+ * The listeners live on the agent's own context, so they go away with the agent
194
+ * and never outlive it — which is exactly why the dedup has to be per agent and
195
+ * not per session: a session whose agent was re-created (a resume, a restart, a
196
+ * rebuilt agent for the same id) has nothing listening on the new one, and every
197
+ * event this module depends on would silently stop arriving. The remembered
198
+ * agent is compared by identity, so a different object re-subscribes and a
199
+ * disposed one's stale entry cannot block it. What the replaced agent held is
200
+ * released first, so re-arming is not a leak: one set of handlers per live agent,
201
+ * not one more per identity change.
202
+ *
203
+ * Called on every ask AND on every poll read (`answers`), because the agent can be
204
+ * replaced while a question is still pending — the case where nothing asks again.
205
+ * @param sessionId - the session whose agent to watch.
206
+ */
207
+ private watch;
208
+ }
209
+ export {};