@vintasoftware/pr-review-canvas 0.1.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/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- package/static/styles.css +14 -0
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The AI Chat pane: threads, the one context a message is about, the streamed answer, and the
|
|
3
|
+
// comment cards an answer can propose. The scrolling rules live in chat-scroll.js and the block
|
|
4
|
+
// parsing in proposed-comment.js; this file is the wiring between them and the DOM.
|
|
5
|
+
/** @typedef {import('./chat-context.js').ChatContext} ChatContext */
|
|
6
|
+
/** @typedef {import('./contract-types.js').ChatTurn} ChatTurn */
|
|
7
|
+
/** @typedef {import('./proposed-comment.js').ProposedComment} ProposedComment */
|
|
8
|
+
/** @typedef {import('./review-session.js').ReviewSession} ReviewSession */
|
|
9
|
+
import { cancelChat, createThread, fetchThreadHistory, fetchThreads, streamChat } from './api.js'
|
|
10
|
+
import { chatContextAttrs, chatContextLabel, sameChatContext, WHOLE_PR } from './chat-context.js'
|
|
11
|
+
import {
|
|
12
|
+
canScrollBy,
|
|
13
|
+
chatScrollReducer,
|
|
14
|
+
INITIAL_SCROLL_STATE,
|
|
15
|
+
isAtBottom,
|
|
16
|
+
redirectsWheelToTranscript,
|
|
17
|
+
scrollBehavior,
|
|
18
|
+
unseenLabel,
|
|
19
|
+
} from './chat-scroll.js'
|
|
20
|
+
import { runCommand, runControl, showCommandError } from './commands.js'
|
|
21
|
+
import { postedCommentUrl, viewCommentHtml } from './comment-link.js'
|
|
22
|
+
import { esc, qs } from './dom.js'
|
|
23
|
+
import { getRenderContext } from './layers.js'
|
|
24
|
+
import { renderMarkdown } from './markdown.js'
|
|
25
|
+
import { splitChatAnswer, targetsFromFiles } from './proposed-comment.js'
|
|
26
|
+
|
|
27
|
+
export const CHAT_WIDTH_KEY = 'pr-review.chat-width'
|
|
28
|
+
export const CHAT_WIDTH_MIN = 280
|
|
29
|
+
export const CHAT_WIDTH_MAX = 560
|
|
30
|
+
export const CHAT_WIDTH_DEFAULT = 340
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{ enabled: boolean, width?: number }} opts
|
|
34
|
+
* @returns {string} '' when AI Chat is disabled
|
|
35
|
+
*/
|
|
36
|
+
export function renderChatShell(opts) {
|
|
37
|
+
if (!opts.enabled) {
|
|
38
|
+
return ''
|
|
39
|
+
}
|
|
40
|
+
const width = clampWidth(opts.width ?? CHAT_WIDTH_DEFAULT)
|
|
41
|
+
return (
|
|
42
|
+
'<aside class="chat" aria-labelledby="chat-h">' +
|
|
43
|
+
`<button class="handle" type="button" id="chat-handle" role="separator" aria-orientation="vertical" aria-label="Resize AI Chat" aria-valuenow="${width}" aria-valuemin="${CHAT_WIDTH_MIN}" aria-valuemax="${CHAT_WIDTH_MAX}"></button>` +
|
|
44
|
+
'<div class="chat-h"><h2 id="chat-h">AI Chat</h2><label class="sr" for="thread">Thread</label>' +
|
|
45
|
+
'<select id="thread"></select>' +
|
|
46
|
+
'<button class="cmd" type="button" id="new-thread">new thread</button></div>' +
|
|
47
|
+
'<p class="ctx-line" id="chat-ctx-line">Context: <span class="ctx-chip" id="chat-ctx">whole PR</span>' +
|
|
48
|
+
' <button class="cmd" type="button" id="chat-clear" hidden>clear</button></p>' +
|
|
49
|
+
'<div class="transcript" id="chat-log" role="log" aria-label="Transcript" aria-live="polite" tabindex="0">' +
|
|
50
|
+
'<p class="empty">Ask AI Chat about a layer, a file, or a selection. Answers come with a verdict first, then evidence.</p>' +
|
|
51
|
+
'</div>' +
|
|
52
|
+
'<button class="cmd unseen" type="button" id="chat-unseen" hidden aria-live="polite"></button>' +
|
|
53
|
+
'<form class="chat-composer" id="chat-form"><label class="sr" for="msg">Message</label>' +
|
|
54
|
+
'<textarea id="msg" rows="3" placeholder="Ask AI Chat about this PR…"></textarea>' +
|
|
55
|
+
'<div class="tbtns"><button class="cmd fill" type="submit" id="chat-send">send</button>' +
|
|
56
|
+
'<button class="cmd" type="button" id="chat-stop" hidden>stop</button>' +
|
|
57
|
+
'<span class="muted small">enter to send</span></div></form>' +
|
|
58
|
+
'</aside>'
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The attribute writer escapes five characters; this reads them back for `setAttribute`.
|
|
64
|
+
* @param {string} value
|
|
65
|
+
*/
|
|
66
|
+
function unescapeAttr(value) {
|
|
67
|
+
return value
|
|
68
|
+
.replace(/"/g, '"')
|
|
69
|
+
.replace(/'/g, "'")
|
|
70
|
+
.replace(/</g, '<')
|
|
71
|
+
.replace(/>/g, '>')
|
|
72
|
+
.replace(/&/g, '&')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {number} width */
|
|
76
|
+
export function clampWidth(width) {
|
|
77
|
+
if (!Number.isFinite(width)) {
|
|
78
|
+
return CHAT_WIDTH_DEFAULT
|
|
79
|
+
}
|
|
80
|
+
return Math.min(CHAT_WIDTH_MAX, Math.max(CHAT_WIDTH_MIN, Math.round(width)))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {Storage | null} storage
|
|
85
|
+
* @returns {number}
|
|
86
|
+
*/
|
|
87
|
+
export function readChatWidth(storage) {
|
|
88
|
+
const raw = storage === null ? null : storage.getItem(CHAT_WIDTH_KEY)
|
|
89
|
+
return clampWidth(raw === null ? CHAT_WIDTH_DEFAULT : Number(raw))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {Storage | null} storage
|
|
94
|
+
* @param {number} width
|
|
95
|
+
*/
|
|
96
|
+
export function writeChatWidth(storage, width) {
|
|
97
|
+
storage?.setItem(CHAT_WIDTH_KEY, String(clampWidth(width)))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The four questions the quick menu offers, and the one that just focuses the box. */
|
|
101
|
+
export const QUICK_QUESTIONS = [
|
|
102
|
+
'Is this covered by tests?',
|
|
103
|
+
'What could break?',
|
|
104
|
+
'Why this way, and what were the alternatives?',
|
|
105
|
+
'Does this follow how the codebase already does it?',
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @typedef {{
|
|
110
|
+
* root: HTMLElement,
|
|
111
|
+
* prNumber: number,
|
|
112
|
+
* session: ReviewSession,
|
|
113
|
+
* storage?: Storage | null,
|
|
114
|
+
* api?: Partial<ChatApi>,
|
|
115
|
+
* reducedMotion?: boolean,
|
|
116
|
+
* onProposed?: (what: 'post' | 'edit', comment: ProposedComment, el: HTMLElement) => void,
|
|
117
|
+
* }} ChatOptions
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @typedef {{
|
|
122
|
+
* fetchThreads: typeof fetchThreads,
|
|
123
|
+
* createThread: typeof createThread,
|
|
124
|
+
* fetchThreadHistory: typeof fetchThreadHistory,
|
|
125
|
+
* cancelChat: typeof cancelChat,
|
|
126
|
+
* streamChat: typeof streamChat,
|
|
127
|
+
* }} ChatApi
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/** @param {Partial<ChatApi> | undefined} overrides */
|
|
131
|
+
function withDefaults(overrides) {
|
|
132
|
+
return { fetchThreads, createThread, fetchThreadHistory, cancelChat, streamChat, ...overrides }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {ChatTurn['role']} role
|
|
137
|
+
* @param {string} bodyHtml
|
|
138
|
+
* @param {{ incomplete?: string }} [opts]
|
|
139
|
+
*/
|
|
140
|
+
function turnHtml(role, bodyHtml, opts = {}) {
|
|
141
|
+
return `<div class="turn ${role === 'assistant' ? 'a' : 'u'}">${turnInnerHtml(role, bodyHtml, opts)}</div>`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {ChatTurn['role']} role
|
|
146
|
+
* @param {string} bodyHtml
|
|
147
|
+
* @param {{ incomplete?: string }} [opts]
|
|
148
|
+
*/
|
|
149
|
+
function turnInnerHtml(role, bodyHtml, opts = {}) {
|
|
150
|
+
const note =
|
|
151
|
+
opts.incomplete === undefined ? '' : `<p class="muted small">the answer stopped early (${esc(opts.incomplete)})</p>`
|
|
152
|
+
return (
|
|
153
|
+
`<span class="role">${role === 'assistant' ? 'AI Chat' : 'You'}</span>` +
|
|
154
|
+
`<div class="prose">${bodyHtml}</div>${note}`
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The card a proposed comment renders as. `post to github` goes through the same path as every
|
|
160
|
+
* other post, so capability gating and the pending state apply here too.
|
|
161
|
+
* @param {ProposedComment} comment
|
|
162
|
+
* @param {string} id the key the pane stores this card's comment under
|
|
163
|
+
* @param {string} [postedUrl]
|
|
164
|
+
*/
|
|
165
|
+
export function proposedCommentHtml(comment, id, postedUrl) {
|
|
166
|
+
const range = comment.startLine === undefined ? `${comment.line}` : `${comment.startLine}–${comment.line}`
|
|
167
|
+
const side = comment.side === 'old' ? ' (old side)' : ''
|
|
168
|
+
return (
|
|
169
|
+
`<div class="proposed" data-proposed="${esc(id)}">` +
|
|
170
|
+
`<div class="proposed-h"><span class="lbl">proposed comment</span>` +
|
|
171
|
+
`<span class="mono">${esc(comment.path)}:${esc(range)}${side}</span></div>` +
|
|
172
|
+
`<div class="prose">${renderMarkdown(comment.body)}</div>` +
|
|
173
|
+
'<span class="tbtns">' +
|
|
174
|
+
(postedUrl === undefined
|
|
175
|
+
? `<button class="cmd fill" type="button" data-act="proposed-post" data-proposed="${esc(id)}" data-needs-post>post to github</button>`
|
|
176
|
+
: viewCommentHtml(postedUrl, true)) +
|
|
177
|
+
`<button class="cmd" type="button" data-act="proposed-edit" data-proposed="${esc(id)}" data-needs-post>edit</button>` +
|
|
178
|
+
`<button class="cmd" type="button" data-copy="${esc(comment.body)}">copy</button>` +
|
|
179
|
+
'</span></div>'
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* One assistant answer as HTML: prose, comment cards, and a code block for a block that claimed
|
|
185
|
+
* to be a comment but does not name a line of this diff.
|
|
186
|
+
* Card keys carry the turn they belong to, so an older answer's card still posts its own comment
|
|
187
|
+
* after a newer answer has drawn cards of its own.
|
|
188
|
+
* @param {string} text
|
|
189
|
+
* @param {import('./proposed-comment.js').CommentTargets} targets
|
|
190
|
+
* @param {Map<string, ProposedComment>} sink cards found, by key
|
|
191
|
+
* @param {ReadonlySet<string>} paths
|
|
192
|
+
* @param {string} [turnKey] the prefix of this turn's card keys
|
|
193
|
+
* @param {ReadonlyArray<import('./contract-types.js').ReviewComment>} [posted]
|
|
194
|
+
*/
|
|
195
|
+
export function answerHtml(text, targets, sink, paths, turnKey = 'turn', posted = []) {
|
|
196
|
+
let index = 0
|
|
197
|
+
return splitChatAnswer(text, targets)
|
|
198
|
+
.map(segment => {
|
|
199
|
+
if (segment.type === 'markdown') {
|
|
200
|
+
return renderMarkdown(segment.text, { paths })
|
|
201
|
+
}
|
|
202
|
+
if (segment.type === 'comment') {
|
|
203
|
+
const id = `${turnKey}-${index}`
|
|
204
|
+
index += 1
|
|
205
|
+
sink.set(id, segment.comment)
|
|
206
|
+
return proposedCommentHtml(segment.comment, id, postedCommentUrl(segment.comment, posted))
|
|
207
|
+
}
|
|
208
|
+
return `<pre class="proposed-invalid"><code>${esc(segment.text)}</code></pre><p class="muted small">${esc(segment.reason)}</p>`
|
|
209
|
+
})
|
|
210
|
+
.join('')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One part of the pane, by selector and by the kind of element it has to be.
|
|
215
|
+
* @template {HTMLElement} T
|
|
216
|
+
* @param {ParentNode} root
|
|
217
|
+
* @param {string} selector
|
|
218
|
+
* @param {new () => T} kind
|
|
219
|
+
* @returns {T}
|
|
220
|
+
*/
|
|
221
|
+
function mustFind(root, selector, kind) {
|
|
222
|
+
const el = root.querySelector(selector)
|
|
223
|
+
if (el instanceof kind) {
|
|
224
|
+
return el
|
|
225
|
+
}
|
|
226
|
+
throw new Error(`the AI Chat pane has no ${selector}`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Wires the pane. The returned handle is what the page uses to set the context from an `[ ask ]`
|
|
231
|
+
* command and to take the wiring down before a re-render.
|
|
232
|
+
* @param {ChatOptions} options
|
|
233
|
+
*/
|
|
234
|
+
export function wireChat(options) {
|
|
235
|
+
const { root, prNumber, session } = options
|
|
236
|
+
const api = withDefaults(options.api)
|
|
237
|
+
const storage =
|
|
238
|
+
options.storage === undefined ? (typeof localStorage === 'undefined' ? null : localStorage) : options.storage
|
|
239
|
+
const paths = new Set(session.artifact.files.map(f => f.path))
|
|
240
|
+
const targets = targetsFromFiles(session.artifact.files)
|
|
241
|
+
const reducedMotion =
|
|
242
|
+
options.reducedMotion ??
|
|
243
|
+
(typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
244
|
+
|
|
245
|
+
const pane = root.querySelector('.chat')
|
|
246
|
+
if (!(pane instanceof HTMLElement)) {
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
// The pane is here, so every part of it is: `renderChatShell` writes them together. A missing
|
|
250
|
+
// one is a mistake in this file, which `mustFind` reports instead of half-wiring the pane.
|
|
251
|
+
const log = mustFind(root, '#chat-log', HTMLElement)
|
|
252
|
+
const box = mustFind(root, '#msg', HTMLTextAreaElement)
|
|
253
|
+
const form = mustFind(root, '#chat-form', HTMLFormElement)
|
|
254
|
+
const select = mustFind(root, '#thread', HTMLSelectElement)
|
|
255
|
+
const chip = mustFind(root, '#chat-ctx', HTMLElement)
|
|
256
|
+
const clearButton = mustFind(root, '#chat-clear', HTMLElement)
|
|
257
|
+
const bubble = mustFind(root, '#chat-unseen', HTMLElement)
|
|
258
|
+
const sendButton = mustFind(root, '#chat-send', HTMLElement)
|
|
259
|
+
const stopButton = mustFind(root, '#chat-stop', HTMLElement)
|
|
260
|
+
|
|
261
|
+
/** @type {ChatContext} */
|
|
262
|
+
let context = WHOLE_PR
|
|
263
|
+
/** @type {import('./chat-scroll.js').ChatScrollState} */
|
|
264
|
+
let scroll = INITIAL_SCROLL_STATE
|
|
265
|
+
/** Where each thread was left, so switching back does not jump the reader to the bottom. */
|
|
266
|
+
const scrollTops = new Map()
|
|
267
|
+
/** Every comment card on screen, by the key its buttons carry. */
|
|
268
|
+
/** @type {Map<string, ProposedComment>} */
|
|
269
|
+
const proposed = new Map()
|
|
270
|
+
const postedComments = () => {
|
|
271
|
+
const ids = new Set(session.state.posted.map(p => p.commentId))
|
|
272
|
+
return getRenderContext()?.comments.filter(c => ids.has(c.id)) ?? []
|
|
273
|
+
}
|
|
274
|
+
/** @type {string | null} */
|
|
275
|
+
let activeThread = null
|
|
276
|
+
let streaming = false
|
|
277
|
+
let stopActivity = () => {}
|
|
278
|
+
/** The turn in flight, so taking the pane down stops its request too. */
|
|
279
|
+
/** @type {AbortController | null} */
|
|
280
|
+
let inFlight = null
|
|
281
|
+
/** @type {number | ReturnType<typeof setTimeout>} */
|
|
282
|
+
let frame = 0
|
|
283
|
+
let turnSeq = 0
|
|
284
|
+
/**
|
|
285
|
+
* Counts the things that replace the whole transcript. A history load that comes back after
|
|
286
|
+
* one of them reads a number that has moved on, and leaves the log to whoever owns it now.
|
|
287
|
+
*/
|
|
288
|
+
let logSeq = 0
|
|
289
|
+
/** True while a thread is being drawn: the scrolling that comes with a redraw is not the reader's. */
|
|
290
|
+
let restoring = false
|
|
291
|
+
|
|
292
|
+
/** Takes the log over and returns the number that says so. */
|
|
293
|
+
const claimLog = () => {
|
|
294
|
+
logSeq += 1
|
|
295
|
+
restoring = false
|
|
296
|
+
return logSeq
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const applyWidth = /** @param {number} width */ width => {
|
|
300
|
+
root.style.setProperty('--chat-w', `${clampWidth(width)}px`)
|
|
301
|
+
const handle = qs('#chat-handle', root)
|
|
302
|
+
handle?.setAttribute('aria-valuenow', String(clampWidth(width)))
|
|
303
|
+
}
|
|
304
|
+
applyWidth(readChatWidth(storage))
|
|
305
|
+
|
|
306
|
+
const layerTitle = /** @param {string} id */ id => session.artifact.layers.find(l => l.id === id)?.title
|
|
307
|
+
const pointTitle = /** @param {string} fp */ fp => session.artifact.points.find(p => p.fingerprint === fp)?.title
|
|
308
|
+
|
|
309
|
+
const drawContext = () => {
|
|
310
|
+
chip.textContent = chatContextLabel(context, layerTitle, pointTitle)
|
|
311
|
+
clearButton.hidden = context.kind === 'pr'
|
|
312
|
+
chip.toggleAttribute('data-ask-menu', context.kind !== 'pr')
|
|
313
|
+
if (context.kind === 'pr') {
|
|
314
|
+
chip.removeAttribute('tabindex')
|
|
315
|
+
chip.removeAttribute('aria-expanded')
|
|
316
|
+
} else {
|
|
317
|
+
chip.tabIndex = 0
|
|
318
|
+
}
|
|
319
|
+
// The quick-question menu reads the target off the chip, so the chip carries it too.
|
|
320
|
+
for (const name of [
|
|
321
|
+
'data-ask-layer',
|
|
322
|
+
'data-ask-path',
|
|
323
|
+
'data-ask-side',
|
|
324
|
+
'data-ask-start',
|
|
325
|
+
'data-ask-end',
|
|
326
|
+
'data-ask-point',
|
|
327
|
+
]) {
|
|
328
|
+
chip.removeAttribute(name)
|
|
329
|
+
}
|
|
330
|
+
for (const pair of chatContextAttrs(context).matchAll(/([a-z-]+)="([^"]*)"/g)) {
|
|
331
|
+
chip.setAttribute(pair[1] ?? '', unescapeAttr(pair[2] ?? ''))
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const drawBubble = () => {
|
|
336
|
+
const label = unseenLabel(scroll)
|
|
337
|
+
bubble.hidden = label === null
|
|
338
|
+
bubble.textContent = label ?? ''
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** @param {'follow' | 'jump'} reason */
|
|
342
|
+
const scrollToBottom = reason => {
|
|
343
|
+
log.scrollTo?.({ top: log.scrollHeight, behavior: scrollBehavior(reason, reducedMotion) })
|
|
344
|
+
log.scrollTop = log.scrollHeight
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** @param {import('./chat-scroll.js').ChatScrollAction} action */
|
|
348
|
+
const dispatch = action => {
|
|
349
|
+
scroll = chatScrollReducer(scroll, action)
|
|
350
|
+
drawBubble()
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const emptyNote = () => {
|
|
354
|
+
const note = log.querySelector('.empty')
|
|
355
|
+
note?.remove()
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Adds one turn and returns the element its body is drawn into, so a streamed answer can be
|
|
360
|
+
* redrawn without looking it up again.
|
|
361
|
+
* @param {ChatTurn['role']} role
|
|
362
|
+
* @param {string} html
|
|
363
|
+
* @param {{ incomplete?: string }} [opts]
|
|
364
|
+
* @returns {{ turn: HTMLElement, body: HTMLElement }}
|
|
365
|
+
*/
|
|
366
|
+
const appendTurn = (role, html, opts = {}) => {
|
|
367
|
+
emptyNote()
|
|
368
|
+
turnSeq += 1
|
|
369
|
+
const id = `chat-turn-${turnSeq}`
|
|
370
|
+
const wasPinned = scroll.pinned
|
|
371
|
+
const turn = document.createElement('div')
|
|
372
|
+
turn.className = `turn ${role === 'assistant' ? 'a' : 'u'}`
|
|
373
|
+
turn.id = id
|
|
374
|
+
turn.innerHTML = turnInnerHtml(role, html, opts)
|
|
375
|
+
log.appendChild(turn)
|
|
376
|
+
dispatch({ type: 'append', id, belowFold: !wasPinned })
|
|
377
|
+
if (scroll.pinned) {
|
|
378
|
+
scrollToBottom('follow')
|
|
379
|
+
}
|
|
380
|
+
const body = turn.querySelector('.prose')
|
|
381
|
+
return { turn, body: body instanceof HTMLElement ? body : turn }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Redraws a streaming answer at most once per frame: a chunk every few milliseconds must not
|
|
386
|
+
* mean a markdown parse every few milliseconds.
|
|
387
|
+
* @param {HTMLElement} body the turn's `.prose` element
|
|
388
|
+
* @param {() => string} html
|
|
389
|
+
*/
|
|
390
|
+
const scheduleRender = (body, html) => {
|
|
391
|
+
if (frame !== 0) {
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
// requestAnimationFrame is what batches the redraws; setTimeout stands in for it in a test
|
|
395
|
+
// environment that has none.
|
|
396
|
+
const run = () => {
|
|
397
|
+
frame = 0
|
|
398
|
+
body.innerHTML = html()
|
|
399
|
+
if (scroll.pinned) {
|
|
400
|
+
scrollToBottom('follow')
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
frame = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : setTimeout(run, 16)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** @param {number | ReturnType<typeof setTimeout>} handle */
|
|
407
|
+
const cancelFrame = handle => {
|
|
408
|
+
if (typeof handle === 'number' && typeof cancelAnimationFrame === 'function') {
|
|
409
|
+
cancelAnimationFrame(handle)
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
clearTimeout(handle)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const drawThreads = /** @param {import('./contract-types.js').ChatThreadsResponse} data */ data => {
|
|
416
|
+
activeThread = data.activeThread
|
|
417
|
+
select.innerHTML = data.threads
|
|
418
|
+
.map(
|
|
419
|
+
t => `<option value="${esc(t.name)}"${t.name === data.activeThread ? ' selected' : ''}>${esc(t.title)}</option>`
|
|
420
|
+
)
|
|
421
|
+
.join('')
|
|
422
|
+
if (data.threads.length === 0) {
|
|
423
|
+
select.innerHTML = '<option value="">Thread 1</option>'
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Draws a thread's saved turns. The log is emptied first, so a question asked while the turns
|
|
429
|
+
* are still on their way is asked into the thread it belongs to and not under another one's.
|
|
430
|
+
* @param {string} name
|
|
431
|
+
*/
|
|
432
|
+
const loadHistory = async name => {
|
|
433
|
+
const seq = claimLog()
|
|
434
|
+
restoring = true
|
|
435
|
+
proposed.clear()
|
|
436
|
+
log.innerHTML = '<p class="empty">Opening this thread…</p>'
|
|
437
|
+
const { turns } = await api.fetchThreadHistory(prNumber, name)
|
|
438
|
+
if (seq !== logSeq) {
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
proposed.clear()
|
|
442
|
+
log.innerHTML =
|
|
443
|
+
turns.length === 0
|
|
444
|
+
? '<p class="empty">Ask AI Chat about a layer, a file, or a selection. Answers come with a verdict first, then evidence.</p>'
|
|
445
|
+
: turns
|
|
446
|
+
.map((turn, i) =>
|
|
447
|
+
turnHtml(
|
|
448
|
+
turn.role,
|
|
449
|
+
turn.role === 'assistant'
|
|
450
|
+
? answerHtml(turn.text, targets, proposed, paths, `history-${i}`, postedComments())
|
|
451
|
+
: renderMarkdown(turn.text, { paths }),
|
|
452
|
+
turn.incomplete === undefined ? {} : { incomplete: turn.incomplete }
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
.join('')
|
|
456
|
+
const saved = scrollTops.get(name)
|
|
457
|
+
dispatch({ type: 'thread-switch', pinned: saved === undefined })
|
|
458
|
+
if (saved === undefined) {
|
|
459
|
+
scrollToBottom('follow')
|
|
460
|
+
} else {
|
|
461
|
+
log.scrollTop = saved
|
|
462
|
+
}
|
|
463
|
+
restoring = false
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** While a turn runs, the thread controls are locked: its callbacks draw into this transcript. */
|
|
467
|
+
/** @param {boolean} on */
|
|
468
|
+
const setStreaming = on => {
|
|
469
|
+
streaming = on
|
|
470
|
+
sendButton.hidden = on
|
|
471
|
+
stopButton.hidden = !on
|
|
472
|
+
box.disabled = on
|
|
473
|
+
select.disabled = on
|
|
474
|
+
const newThread = qs('#new-thread', root)
|
|
475
|
+
if (newThread instanceof HTMLButtonElement) {
|
|
476
|
+
newThread.disabled = on
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The answer as HTML. A redraw replaces this turn's cards and leaves every other turn's alone.
|
|
482
|
+
* @param {string} text
|
|
483
|
+
* @param {string} turnKey
|
|
484
|
+
*/
|
|
485
|
+
const renderAnswer = (text, turnKey) => {
|
|
486
|
+
for (const key of [...proposed.keys()]) {
|
|
487
|
+
if (key.startsWith(`${turnKey}-`)) {
|
|
488
|
+
proposed.delete(key)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return answerHtml(text, targets, proposed, paths, turnKey, postedComments())
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const send = async () => {
|
|
495
|
+
const message = box.value.trim()
|
|
496
|
+
if (message === '' || streaming) {
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
box.value = ''
|
|
500
|
+
// The turn owns the log from here on, so a history load still on its way lands nowhere.
|
|
501
|
+
claimLog()
|
|
502
|
+
appendTurn('user', renderMarkdown(message, { paths }))
|
|
503
|
+
dispatch({ type: 'send' })
|
|
504
|
+
scrollToBottom('follow')
|
|
505
|
+
const answer = appendTurn('assistant', '')
|
|
506
|
+
const activity = document.createElement('p')
|
|
507
|
+
activity.className = 'muted small chat-activity'
|
|
508
|
+
activity.setAttribute('aria-live', 'off')
|
|
509
|
+
answer.turn.append(activity)
|
|
510
|
+
const started = Date.now()
|
|
511
|
+
const updateActivity = () => {
|
|
512
|
+
const elapsed = Date.now() - started
|
|
513
|
+
activity.textContent = `Preparing answer${'.'.repeat(Math.floor(elapsed / 400) % 3 + 1)} · ${Math.floor(elapsed / 1000)}s`
|
|
514
|
+
}
|
|
515
|
+
updateActivity()
|
|
516
|
+
const timer = setInterval(updateActivity, 400)
|
|
517
|
+
stopActivity = () => clearInterval(timer)
|
|
518
|
+
const toolDetails = document.createElement('details')
|
|
519
|
+
toolDetails.className = 'chat-tool-calls muted small'
|
|
520
|
+
toolDetails.hidden = true
|
|
521
|
+
answer.turn.append(toolDetails)
|
|
522
|
+
const tools = new Map()
|
|
523
|
+
const turnKey = answer.turn.id
|
|
524
|
+
setStreaming(true)
|
|
525
|
+
let text = ''
|
|
526
|
+
inFlight = new AbortController()
|
|
527
|
+
try {
|
|
528
|
+
await api.streamChat(
|
|
529
|
+
prNumber,
|
|
530
|
+
{ message, context, ...(activeThread === null ? {} : { thread: activeThread }) },
|
|
531
|
+
{
|
|
532
|
+
signal: inFlight.signal,
|
|
533
|
+
onEvent: event => {
|
|
534
|
+
const data = /** @type {Record<string, unknown>} */ (event.data ?? {})
|
|
535
|
+
const field = /** @param {string} key */ key => data[key]
|
|
536
|
+
const thread = field('thread')
|
|
537
|
+
if (event.event === 'turn' && typeof thread === 'string') {
|
|
538
|
+
activeThread = thread
|
|
539
|
+
void refreshThreads()
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
if (event.event === 'tool') {
|
|
543
|
+
const id = String(field('id') ?? '')
|
|
544
|
+
const previous = tools.get(id)
|
|
545
|
+
const title = String(field('title') ?? 'tool')
|
|
546
|
+
tools.delete(id)
|
|
547
|
+
tools.set(id, { title: title === 'tool' && previous ? previous.title : title, status: String(field('status') ?? 'pending') })
|
|
548
|
+
toolDetails.hidden = false
|
|
549
|
+
const latest = tools.get(id)
|
|
550
|
+
toolDetails.innerHTML = `<summary>${esc(latest.title)} · ${esc(latest.status)} (${tools.size} tool calls)</summary><ul>${[...tools.values()].map(t => `<li>${esc(t.title)} · ${esc(t.status)}</li>`).join('')}</ul>`
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
const chunk = field('text')
|
|
554
|
+
if (event.event === 'chunk' && typeof chunk === 'string') {
|
|
555
|
+
text += chunk
|
|
556
|
+
// An answer growing out of sight is one thing to catch up on, however many chunks
|
|
557
|
+
// it arrives in; catching up clears the count, so drifting away again counts anew.
|
|
558
|
+
if (!scroll.pinned && scroll.unseen === 0) {
|
|
559
|
+
dispatch({ type: 'append', id: turnKey, belowFold: true })
|
|
560
|
+
}
|
|
561
|
+
scheduleRender(answer.body, () => renderAnswer(text, turnKey))
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
if (event.event === 'error') {
|
|
565
|
+
showCommandError(stopButton, String(field('message') ?? 'the agent failed'))
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
if (event.event === 'cancelled') {
|
|
569
|
+
answer.turn.insertAdjacentHTML('beforeend', '<p class="muted small">stopped</p>')
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
}
|
|
573
|
+
)
|
|
574
|
+
} finally {
|
|
575
|
+
inFlight = null
|
|
576
|
+
stopActivity()
|
|
577
|
+
toolDetails.open = false
|
|
578
|
+
const toolSummary = toolDetails.querySelector('summary')
|
|
579
|
+
if (toolSummary) toolSummary.textContent = `${tools.size} tool calls`
|
|
580
|
+
activity.textContent = `Elapsed: ${Math.floor((Date.now() - started) / 1000)}s`
|
|
581
|
+
if (frame !== 0) { cancelFrame(frame); frame = 0 }
|
|
582
|
+
setStreaming(false)
|
|
583
|
+
answer.body.innerHTML = text === '' ? '<span class="muted">no answer</span>' : renderAnswer(text, turnKey)
|
|
584
|
+
if (scroll.pinned) {
|
|
585
|
+
scrollToBottom('follow')
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const refreshThreads = async () => {
|
|
591
|
+
drawThreads(await api.fetchThreads(prNumber))
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** On the first render, the thread that was open last time comes back with its transcript. */
|
|
595
|
+
const restoreThread = async () => {
|
|
596
|
+
const seq = claimLog()
|
|
597
|
+
const data = await api.fetchThreads(prNumber)
|
|
598
|
+
// A question sent while the thread list was on its way owns the pane now.
|
|
599
|
+
if (seq !== logSeq) {
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
drawThreads(data)
|
|
603
|
+
if (data.activeThread !== null) {
|
|
604
|
+
await loadHistory(data.activeThread)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** @param {Event} event */
|
|
609
|
+
const onSubmit = event => {
|
|
610
|
+
event.preventDefault()
|
|
611
|
+
void runCommand(sendButton, send, { pendingLabel: 'sending…' })
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** @param {Event} event */
|
|
615
|
+
const onClick = event => {
|
|
616
|
+
const el = event.target instanceof Element ? event.target.closest('button') : null
|
|
617
|
+
if (!(el instanceof HTMLElement)) {
|
|
618
|
+
return
|
|
619
|
+
}
|
|
620
|
+
if (el.id === 'chat-clear') {
|
|
621
|
+
setContext(WHOLE_PR)
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
if (el.id === 'chat-stop') {
|
|
625
|
+
void runCommand(el, () => api.cancelChat(prNumber), { pendingLabel: 'stopping…' })
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
if (el.id === 'new-thread') {
|
|
629
|
+
void runCommand(
|
|
630
|
+
el,
|
|
631
|
+
async () => {
|
|
632
|
+
const seq = claimLog()
|
|
633
|
+
const data = await api.createThread(prNumber)
|
|
634
|
+
if (seq !== logSeq) {
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
drawThreads(data)
|
|
638
|
+
proposed.clear()
|
|
639
|
+
log.innerHTML = '<p class="empty">New thread. Ask about a layer, a file, or a selection.</p>'
|
|
640
|
+
dispatch({ type: 'thread-switch', pinned: true })
|
|
641
|
+
},
|
|
642
|
+
{ pendingLabel: 'starting…' }
|
|
643
|
+
)
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
if (el.id === 'chat-unseen') {
|
|
647
|
+
const target = scroll.firstUnseenId === null ? null : log.querySelector(`#${scroll.firstUnseenId}`)
|
|
648
|
+
dispatch({ type: 'jump' })
|
|
649
|
+
if (target !== null && typeof target.scrollIntoView === 'function') {
|
|
650
|
+
target.scrollIntoView({ block: 'start', behavior: scrollBehavior('jump', reducedMotion) })
|
|
651
|
+
} else {
|
|
652
|
+
scrollToBottom('jump')
|
|
653
|
+
}
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
const act = el.getAttribute('data-act')
|
|
657
|
+
if (act === 'proposed-post' || act === 'proposed-edit') {
|
|
658
|
+
const comment = proposed.get(el.getAttribute('data-proposed') ?? '')
|
|
659
|
+
if (comment !== undefined) {
|
|
660
|
+
options.onProposed?.(act === 'proposed-post' ? 'post' : 'edit', comment, el)
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const onScroll = () => {
|
|
666
|
+
// Emptying the log puts it back to the top; that is the redraw moving, not the reader.
|
|
667
|
+
if (restoring) {
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
if (activeThread !== null) {
|
|
671
|
+
scrollTops.set(activeThread, log.scrollTop)
|
|
672
|
+
}
|
|
673
|
+
dispatch({ type: 'scroll', atBottom: isAtBottom(log) })
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* A wheel turn anywhere over the pinned pane moves the transcript, so the page does not scroll
|
|
678
|
+
* out from under a reader who is reading the answer.
|
|
679
|
+
* @param {WheelEvent} event
|
|
680
|
+
*/
|
|
681
|
+
const onPaneWheel = event => {
|
|
682
|
+
const target = event.target instanceof Element ? event.target : null
|
|
683
|
+
const insideOwnScroller =
|
|
684
|
+
target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement
|
|
685
|
+
? canScrollBy(target, event.deltaY)
|
|
686
|
+
: false
|
|
687
|
+
const at = {
|
|
688
|
+
// The narrow layout drops the pin and the pane scrolls with the page like any other column.
|
|
689
|
+
pinned: getComputedStyle(pane).position === 'sticky',
|
|
690
|
+
insideTranscript: target?.closest('.transcript') !== null && target !== null,
|
|
691
|
+
zooming: event.ctrlKey,
|
|
692
|
+
insideOwnScroller,
|
|
693
|
+
}
|
|
694
|
+
if (!redirectsWheelToTranscript(at)) {
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
// Consumed whether or not the transcript can move, so the page stays put under the pointer.
|
|
698
|
+
event.preventDefault()
|
|
699
|
+
log.scrollTop += event.deltaY
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** @param {Event} event */
|
|
703
|
+
const onThreadChange = event => {
|
|
704
|
+
const name = event.target instanceof HTMLSelectElement ? event.target.value : ''
|
|
705
|
+
if (name === '' || name === activeThread) {
|
|
706
|
+
return
|
|
707
|
+
}
|
|
708
|
+
activeThread = name
|
|
709
|
+
void runControl(select, () => loadHistory(name))
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** @param {KeyboardEvent} event */
|
|
713
|
+
const onKeyDown = event => {
|
|
714
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
715
|
+
event.preventDefault()
|
|
716
|
+
void runCommand(sendButton, send, { pendingLabel: 'sending…' })
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Sets the context. The same target twice is one state change, not a toggle. */
|
|
721
|
+
const setContext = /** @param {ChatContext} next */ next => {
|
|
722
|
+
if (sameChatContext(context, next)) {
|
|
723
|
+
return false
|
|
724
|
+
}
|
|
725
|
+
context = next
|
|
726
|
+
drawContext()
|
|
727
|
+
return true
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const stopResize = wireResize(pane, root, storage, applyWidth)
|
|
731
|
+
|
|
732
|
+
form.addEventListener('submit', onSubmit)
|
|
733
|
+
root.addEventListener('click', onClick)
|
|
734
|
+
log.addEventListener('scroll', onScroll)
|
|
735
|
+
pane.addEventListener('wheel', onPaneWheel, { passive: false })
|
|
736
|
+
select.addEventListener('change', onThreadChange)
|
|
737
|
+
box.addEventListener('keydown', /** @type {EventListener} */ (onKeyDown))
|
|
738
|
+
drawContext()
|
|
739
|
+
void restoreThread().catch(() => undefined)
|
|
740
|
+
|
|
741
|
+
return {
|
|
742
|
+
get context() {
|
|
743
|
+
return context
|
|
744
|
+
},
|
|
745
|
+
setContext,
|
|
746
|
+
focusInput() {
|
|
747
|
+
box.focus()
|
|
748
|
+
},
|
|
749
|
+
/** @param {ChatContext} next */
|
|
750
|
+
ask(next) {
|
|
751
|
+
setContext(next)
|
|
752
|
+
box.focus()
|
|
753
|
+
},
|
|
754
|
+
/**
|
|
755
|
+
* Sends one of the quick questions about a target.
|
|
756
|
+
* @param {ChatContext} next
|
|
757
|
+
* @param {string} question
|
|
758
|
+
*/
|
|
759
|
+
askQuestion(next, question) {
|
|
760
|
+
setContext(next)
|
|
761
|
+
box.value = question
|
|
762
|
+
void runCommand(sendButton, send, { pendingLabel: 'sending…' })
|
|
763
|
+
},
|
|
764
|
+
stop() {
|
|
765
|
+
stopActivity()
|
|
766
|
+
inFlight?.abort()
|
|
767
|
+
inFlight = null
|
|
768
|
+
if (frame !== 0) {
|
|
769
|
+
cancelFrame(frame)
|
|
770
|
+
frame = 0
|
|
771
|
+
}
|
|
772
|
+
form.removeEventListener('submit', onSubmit)
|
|
773
|
+
root.removeEventListener('click', onClick)
|
|
774
|
+
log.removeEventListener('scroll', onScroll)
|
|
775
|
+
select.removeEventListener('change', onThreadChange)
|
|
776
|
+
box.removeEventListener('keydown', /** @type {EventListener} */ (onKeyDown))
|
|
777
|
+
stopResize()
|
|
778
|
+
},
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Dragging the pane's edge changes its width, between the two bounds, and the browser remembers
|
|
784
|
+
* it. Arrow keys move it too, so the handle works without a pointer.
|
|
785
|
+
* @param {HTMLElement} pane
|
|
786
|
+
* @param {HTMLElement} root
|
|
787
|
+
* @param {Storage | null} storage
|
|
788
|
+
* @param {(width: number) => void} applyWidth
|
|
789
|
+
*/
|
|
790
|
+
export function wireResize(pane, root, storage, applyWidth) {
|
|
791
|
+
const handle = qs('#chat-handle', root)
|
|
792
|
+
if (!(handle instanceof HTMLElement)) {
|
|
793
|
+
return () => undefined
|
|
794
|
+
}
|
|
795
|
+
let dragging = false
|
|
796
|
+
const widthNow = () => readChatWidth(storage)
|
|
797
|
+
|
|
798
|
+
/** @param {PointerEvent} event */
|
|
799
|
+
const onDown = event => {
|
|
800
|
+
dragging = true
|
|
801
|
+
event.preventDefault()
|
|
802
|
+
}
|
|
803
|
+
/** @param {PointerEvent} event */
|
|
804
|
+
const onMove = event => {
|
|
805
|
+
if (!dragging) {
|
|
806
|
+
return
|
|
807
|
+
}
|
|
808
|
+
// The pane is on the right, so dragging left makes it wider.
|
|
809
|
+
const width = clampWidth(window.innerWidth - event.clientX)
|
|
810
|
+
applyWidth(width)
|
|
811
|
+
}
|
|
812
|
+
const onUp = () => {
|
|
813
|
+
if (!dragging) {
|
|
814
|
+
return
|
|
815
|
+
}
|
|
816
|
+
dragging = false
|
|
817
|
+
writeChatWidth(storage, pane.getBoundingClientRect().width || widthNow())
|
|
818
|
+
}
|
|
819
|
+
/** @param {KeyboardEvent} event */
|
|
820
|
+
const onKey = event => {
|
|
821
|
+
const step = event.key === 'ArrowLeft' ? 16 : event.key === 'ArrowRight' ? -16 : 0
|
|
822
|
+
if (step === 0) {
|
|
823
|
+
return
|
|
824
|
+
}
|
|
825
|
+
event.preventDefault()
|
|
826
|
+
const width = clampWidth(widthNow() + step)
|
|
827
|
+
applyWidth(width)
|
|
828
|
+
writeChatWidth(storage, width)
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
handle.addEventListener('pointerdown', /** @type {EventListener} */ (onDown))
|
|
832
|
+
document.addEventListener('pointermove', /** @type {EventListener} */ (onMove))
|
|
833
|
+
document.addEventListener('pointerup', onUp)
|
|
834
|
+
document.addEventListener('pointercancel', onUp)
|
|
835
|
+
handle.addEventListener('keydown', /** @type {EventListener} */ (onKey))
|
|
836
|
+
return () => {
|
|
837
|
+
handle.removeEventListener('pointerdown', /** @type {EventListener} */ (onDown))
|
|
838
|
+
document.removeEventListener('pointermove', /** @type {EventListener} */ (onMove))
|
|
839
|
+
document.removeEventListener('pointerup', onUp)
|
|
840
|
+
document.removeEventListener('pointercancel', onUp)
|
|
841
|
+
handle.removeEventListener('keydown', /** @type {EventListener} */ (onKey))
|
|
842
|
+
}
|
|
843
|
+
}
|