@davait/aipictury 1.19.4 → 1.20.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.
package/ext/extension.js CHANGED
@@ -16,7 +16,9 @@ const files = require('./files')
16
16
  const shell = require('./shell')
17
17
  const mcp = require('./mcp')
18
18
  const voice = require('./voice')
19
- const { RemoteLink } = require('./remote')
19
+ const attachmentShelf = require('./attachments')
20
+ const { RemoteLink, SavedLinks } = require('./remote')
21
+ const { remoteLive, REMOTE_BEAT } = require('./cloud')
20
22
  const context = require('./context')
21
23
  const { showAbout } = require('./about')
22
24
  const { stripInvisible } = require('./text')
@@ -24,6 +26,7 @@ const sessions = require('./sessions')
24
26
  const log = require('./log')
25
27
  const workbench = require('./workbench')
26
28
  const dialogs = require('./dialogs')
29
+ const feedback = require('./feedback')
27
30
  const userHooks = require('./hooks')
28
31
  const scheduler = require('./scheduler')
29
32
  const usageLog = require('./usage')
@@ -93,6 +96,7 @@ function appendText(message, text) {
93
96
 
94
97
  const STORE = 'aipictury.conversations'
95
98
  const LIVE = 'aipictury.live' // the conversation on screen, and whether it was mid-run
99
+ const DICTATION = 'aipictury.dictation' // a recording not written yet: { file, at } (voice.js keeps it)
96
100
  const MAX_ATTACH = 10 // files per message, as in the web assistant
97
101
  const ATTACH_TYPES = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'pdf', 'txt', 'md', 'json', 'csv',
98
102
  'log', 'yaml', 'yml', 'py', 'js', 'jsx', 'ts', 'tsx', 'sh', 'sql', 'html', 'css', 'xml', 'toml',
@@ -100,6 +104,7 @@ const ATTACH_TYPES = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'pdf', 'txt',
100
104
  const CHECKPOINTS = 100 // file snapshots kept per conversation, as Claude Code keeps
101
105
  const RESTORE_WINDOW = 10 * 60_000 // a side-bar chat comes back after a reload if this recent
102
106
  const CONTINUE_WINDOW = 60 * 60_000 // and a run cut by the reload continues if this recent
107
+ const FLUSH_BUDGET = 4000 // ms the account's last pushes get when VS Code closes (it allows ~5 s)
103
108
 
104
109
  // Commands typed after /, handled here without the model. Custom commands from the
105
110
  // project's .aipictury/commands or .claude/commands and skills are listed after these.
@@ -160,13 +165,17 @@ const REVIEW_PROMPT = 'Review the uncommitted changes in this repository (git st
160
165
  + 'Do not change any file.'
161
166
 
162
167
  class AgentPanel {
163
- constructor(context, { kind = 'sidebar', registry } = {}) {
168
+ constructor(context, { kind = 'sidebar', registry, slot } = {}) {
164
169
  this.context = context
165
170
  this.kind = kind
171
+ // Where this panel's unsent attachments are kept across a reload (attachments.js): the side
172
+ // bar has one; a tab its own, carried in its webview state (postSession).
173
+ this.slot = kind === 'sidebar' ? 'sidebar' : slot || attachmentShelf.newSlot()
166
174
  this.registry = registry
167
175
  this.history = []
168
176
  this.session = { allowAll: false }
169
177
  this.host = null // the WebviewView or WebviewPanel showing this conversation
178
+ this.recording = null // dictation in progress (dictate)
170
179
  this.abort = null
171
180
  this.id = null // which stored conversation this is, once it has content
172
181
  this.model = context.globalState.get('aipictury.model') || null // override, or the role default
@@ -228,6 +237,7 @@ class AgentPanel {
228
237
  usage: this.usage,
229
238
  }
230
239
  this.store.save(entry)
240
+ if (this.remote && !this.remote.keptFor) this.savedLinks?.keep(this.id, this.remote).catch(() => {})
231
241
  await this.saveCheckpoints()
232
242
  if (this.kind === 'tab' && this.host) this.host.title = `AIPictury: ${entry.title.slice(0, 30)}`
233
243
  this.postSession()
@@ -281,7 +291,9 @@ class AgentPanel {
281
291
  const found = this.store.get(id)
282
292
  if (!found) return false
283
293
  if (this.abort) { vscode.window.showInformationMessage('Stop the agent before switching conversations.'); return false }
294
+ this.stopDelivering() // the last one's render goes on: delivered when it is opened again
284
295
  await this.remember() // do not lose the one on screen
296
+ if (this.remote && this.remote.keptFor !== id) this.letGoOfRemote() // its link goes with it
285
297
  this.id = found.id
286
298
  this.created = found.created || found.updated
287
299
  this.history = found.history
@@ -295,6 +307,14 @@ class AgentPanel {
295
307
  this.session = { allowAll: false } // a reopened conversation asks again
296
308
  // Older entries predate the stored transcript; rebuild what can be rebuilt.
297
309
  this.transcript = found.transcript || fromHistory(found.history)
310
+ // A row the window closed on mid-step said "rendering 40%" for ever: a render with its job id
311
+ // is followed again below, anything else is said to be cut off (1.20.0).
312
+ const followed = new Set(this.history.filter((m) => m.pending).map((m) => m.pending.row))
313
+ for (const t of this.transcript) {
314
+ if (t.kind === 'tool' && ['running', 'asking'].includes(t.status) && !followed.has(t.key)) {
315
+ Object.assign(t, { status: 'failed', result: 'cut off: the window was closed or reloaded while this ran' })
316
+ }
317
+ }
298
318
  if (found.unread) this.store.patch(found.id, { unread: undefined }) // seen now
299
319
  this.startSource = 'resume' // for the user's SessionStart hooks, at the next run
300
320
  if (this.kind === 'tab' && this.host) this.host.title = `AIPictury: ${String(found.title).slice(0, 30)}`
@@ -302,6 +322,9 @@ class AgentPanel {
302
322
  if (this.btw.length) this.post({ type: 'btwThread', items: this.btw })
303
323
  this.postSession()
304
324
  this.registry?.sessionsView?.refresh()
325
+ this.deliverPending()
326
+ this.refreshKeeping().catch(() => {})
327
+ this.rehostRemote(found.id).catch((error) => log.warn('remote control not hosted again', error))
305
328
  if (focus && this.kind === 'sidebar') await vscode.commands.executeCommand('aipictury.agent.focus')
306
329
  return true
307
330
  }
@@ -333,9 +356,11 @@ class AgentPanel {
333
356
  clearInterval(poll); clearInterval(ticker); subs.forEach((s) => s.dispose())
334
357
  clearInterval(this.gpuWatch); this.gpuWatch = null
335
358
  this.abort?.abort()
359
+ this.delivering?.abort()
336
360
  this.recording?.cancel()
337
- if (this.remote) this.registry?.account?.cloud?.dropRemote?.(this.remote.id).catch(() => {})
338
- this.remote?.stop()
361
+ // The panel going (closed, or the window reloading) is not "stop sharing": its link is kept
362
+ // and the same address carries on when the conversation is on screen again (1.20.0).
363
+ this.letGoOfRemote()
339
364
  this.remember().catch(() => {})
340
365
  this.registry?.remove(this)
341
366
  })
@@ -399,7 +424,7 @@ class AgentPanel {
399
424
  }
400
425
 
401
426
  postSession() {
402
- this.post({ type: 'session', id: this.id, title: this.conversationTitle, kind: this.kind })
427
+ this.post({ type: 'session', id: this.id, title: this.conversationTitle, kind: this.kind, slot: this.slot })
403
428
  }
404
429
 
405
430
  postCommands() {
@@ -415,6 +440,7 @@ class AgentPanel {
415
440
  this.postAccount(this.registry?.account?.user?.() ?? null)
416
441
  this.refreshStatus(); this.postMode(); this.refreshCache(); this.postCommands(); this.postSession()
417
442
  editorTracker.post(this)
443
+ this.restoreAttachments()
418
444
  // Reloaded while a question was open: the agent is still waiting for it.
419
445
  if (this.pendingAsk) this.post({ type: 'askUser', id: this.pendingAsk.id, questions: this.pendingAsk.questions })
420
446
  for (const [id, p] of this.approvals) this.post({ type: 'approval', id, request: p.request })
@@ -585,6 +611,7 @@ class AgentPanel {
585
611
  async reset() {
586
612
  if (this.remote) await this.remoteControl('stop') // the link was for the conversation that ends here
587
613
  if (this.abort) this.abort.abort()
614
+ this.stopDelivering() // the render goes on: delivered when that one is opened again
588
615
  await this.remember()
589
616
  if (this.id) this.fireHook('SessionEnd', { reason: 'clear' }).catch(() => {})
590
617
  this.startSource = 'clear'
@@ -635,7 +662,7 @@ class AgentPanel {
635
662
  const say = (note) => this.say(note)
636
663
  if (/^#/.test(text) && /\S/.test(text.replace(/^#+/, ''))) this.post({ type: 'user', text })
637
664
  if (/^##\s*\S/.test(text)) {
638
- say(await memory.addUserRule(serverUrl(), text.replace(/^##\s*/, '')))
665
+ try { say(await memory.addUserRule(serverUrl(), text.replace(/^##\s*/, ''))) } catch (e) { say(e.message) }
639
666
  return true
640
667
  }
641
668
  if (/^#\s*\S/.test(text)) {
@@ -1011,6 +1038,21 @@ class AgentPanel {
1011
1038
  this.post({ type: 'attachments', max: MAX_ATTACH, items: this.attachments.map((a) => ({
1012
1039
  id: a.id, name: a.name, kind: a.kind,
1013
1040
  thumb: a.kind === 'image' ? `data:image/*;base64,${a.image}` : undefined })) })
1041
+ // Every change to what is attached comes through here: kept as it now is, so a reload before
1042
+ // Send brings the chips back and the message still carries them (1.20.0).
1043
+ if (this.attachmentsRestored) attachmentShelf.save(workspaceRoot()?.fsPath, this.slot, this.attachments)
1044
+ }
1045
+
1046
+ /** The panel (re)loaded: what was attached and not sent before a reload is back, once — and
1047
+ * shown again whenever the webview itself reloads (it forgets its chips). */
1048
+ restoreAttachments() {
1049
+ if (!this.attachmentsRestored) {
1050
+ this.attachmentsRestored = true
1051
+ const here = new Set(this.attachments.map((a) => a.id))
1052
+ const kept = attachmentShelf.load(workspaceRoot()?.fsPath, this.slot).filter((a) => !here.has(a.id))
1053
+ this.attachments = [...this.attachments, ...kept].slice(0, MAX_ATTACH)
1054
+ }
1055
+ this.postAttachments()
1014
1056
  }
1015
1057
 
1016
1058
  /** Tells the panel which model is answering, so the user is never guessing. */
@@ -1057,22 +1099,41 @@ class AgentPanel {
1057
1099
 
1058
1100
  postQueue() { this.post({ type: 'queue', items: this.queue.map((q) => q.text) }) }
1059
1101
 
1060
- /** While this conversation is worked from elsewhere (remote control): the account's devices
1061
- * are told — a step waiting for a yes, a run done (the server's push.py; a click opens it). */
1062
- notifyAway(title, body) {
1063
- if (!this.remote?.url) return
1064
- const server = serverUrl()
1065
- fetch(`${server}/api/push/notify`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
1066
- body: JSON.stringify({ title, body: String(body).slice(0, 300), link: this.remote.url, tag: `remote-${this.id || 'new'}` }),
1067
- signal: AbortSignal.timeout(15_000) }).catch(() => {})
1102
+ /** Where the links shared by remote control are kept across reloads (remote.js). */
1103
+ get savedLinks() {
1104
+ return (this._savedLinks ||= this.context.secrets ? new SavedLinks({ secrets: this.context.secrets, state: this.context.workspaceState }) : null)
1105
+ }
1106
+
1107
+ /** This panel stops hosting its link without ending it: the server keeps the conversation a
1108
+ * while, the link is kept (SavedLinks) and hosted again with its conversation. */
1109
+ letGoOfRemote() {
1110
+ if (!this.remote) return
1111
+ clearInterval(this.remoteBeat)
1112
+ const link = this.remote
1113
+ this.remote = null
1114
+ if (link.keptFor) this.savedLinks?.keep(link.keptFor, link).catch(() => {}) // hosted until now
1115
+ link.stop({ end: false })
1116
+ this.registry?.account?.cloud?.dropRemote?.(link.id).catch(() => {}) // listed again when hosted again
1117
+ try { this.post({ type: 'remote', on: false }) } catch { /* the panel itself is going */ }
1118
+ }
1119
+
1120
+ /** A conversation on screen again whose link was hosted until a moment ago (a reload, a crash,
1121
+ * its panel closed): the same link is hosted again, so whoever has it carries on (1.20.0). */
1122
+ async rehostRemote(conversation) {
1123
+ if (this.remote || !conversation) return
1124
+ const saved = await this.savedLinks?.take(conversation).catch(() => null)
1125
+ if (!saved || this.remote || this.id !== conversation) return
1126
+ await this.remoteControl('start', { restored: saved })
1068
1127
  }
1069
1128
 
1070
1129
  /** Remote control: this conversation, carried on from a browser (remote.js). */
1071
- async remoteControl(action, { asked = false } = {}) {
1130
+ async remoteControl(action, { asked = false, restored = null } = {}) {
1072
1131
  if (action === 'stop') {
1073
1132
  if (!this.remote) { this.say('remote control is not on', 'remote'); return }
1133
+ clearInterval(this.remoteBeat)
1074
1134
  this.remote.stop()
1075
1135
  this.registry?.account?.cloud?.dropRemote?.(this.remote.id).catch(() => {})
1136
+ this.savedLinks?.forget(this.remote.keptFor || this.id).catch(() => {}) // stopped on purpose: gone for good
1076
1137
  this.remote = null
1077
1138
  this.post({ type: 'remote', on: false })
1078
1139
  this.say('remote control ended — the link no longer works', 'remote')
@@ -1084,19 +1145,43 @@ class AgentPanel {
1084
1145
  token: () => this.registry?.account?.account.idToken(),
1085
1146
  log: (line) => log.warn(line),
1086
1147
  onViewers: (n) => this.post({ type: 'remote', on: true, viewers: n, url: this.remote?.url }),
1148
+ id: restored?.id, key: restored?.key,
1087
1149
  }).start()
1150
+ // Kept with its conversation, and its time refreshed while on: a reload or a crash can then
1151
+ // host the same link again (rehostRemote). A conversation with no id yet is kept at its first save.
1152
+ // Its listing with the account is written again on the same beat: one a crash left behind
1153
+ // stops being fresh, and aipictury.com no longer offers it (cloud.remoteLive, 1.20.0).
1154
+ const link = this.remote
1155
+ this.savedLinks?.keep(this.id, link).catch(() => {})
1156
+ this.remoteBeat = setInterval(() => {
1157
+ if (this.remote !== link) return
1158
+ this.savedLinks?.keep(this.id, link).catch(() => {})
1159
+ this.listRemote()
1160
+ }, REMOTE_BEAT)
1161
+ }
1162
+ if (restored) {
1163
+ this.post({ type: 'remote', on: true, viewers: this.remote.viewers, url: this.remote.url })
1164
+ this.listRemote()
1165
+ this.say('remote control is on again — the link from before still works. /remote-control stop ends it.', 'remote')
1166
+ return
1088
1167
  }
1089
1168
  if (!asked) await vscode.env.clipboard.writeText(this.remote.url) // asked from the web: it has the link
1090
1169
  this.post({ type: 'remote', on: true, viewers: this.remote.viewers, url: this.remote.url })
1091
- // Listed with the account, so aipictury.com offers to control it (Live in VS Code).
1092
- const title = this.title || String(this.history.find((m) => m.role === 'user')?.content || 'A conversation').split('\n')[0]
1093
- this.registry?.account?.cloud?.putRemote?.(this.remote.id, { url: this.remote.url, title: title.slice(0, 200),
1094
- device: require('os').hostname().slice(0, 80), conversation: this.id || '' }).catch((e) => log.warn(`remote not listed with the account: ${e.message}`))
1170
+ this.listRemote()
1095
1171
  if (asked) { this.say('remote control is on — asked from aipictury.com, where it is open now. /remote-control stop ends it.', 'remote'); return }
1096
1172
  this.say(`remote control is on — the link is copied: open it in a browser or on your phone (on your network). Anyone with it `
1097
1173
  + 'can drive this conversation; yes or no to a step is given there, rules and modes stay here. /remote-control stop ends it.', 'remote')
1098
1174
  }
1099
1175
 
1176
+ /** Listed with the account, so aipictury.com offers to control it (Live in VS Code). */
1177
+ listRemote() {
1178
+ if (!this.remote) return
1179
+ const title = this.title || String(this.history.find((m) => m.role === 'user')?.content || 'A conversation').split('\n')[0]
1180
+ this.remote.listedAt ||= Date.now()
1181
+ this.registry?.account?.cloud?.putRemote?.(this.remote.id, { url: this.remote.url, title: title.slice(0, 200),
1182
+ device: require('os').hostname().slice(0, 80), conversation: this.id || '', started: this.remote.listedAt }).catch((e) => log.warn(`remote not listed with the account: ${e.message}`))
1183
+ }
1184
+
1100
1185
  /** Dictation: recorded here (webviews have no microphone), written by the server's Whisper,
1101
1186
  * and put into the input — never sent. 'toggle' starts or stops; 'cancel' discards. */
1102
1187
  async dictate(action) {
@@ -1117,12 +1202,59 @@ class AgentPanel {
1117
1202
  if (!rec) return
1118
1203
  this.recording = null
1119
1204
  say('writing')
1205
+ let file
1206
+ try { file = await rec.stop() } catch (error) { say('idle', { error: `Not written: ${error.message}` }); return }
1207
+ await this.writeDictation(file)
1208
+ }
1209
+
1210
+ /** A recording written by the server into the input. Not written (the server away, restarting,
1211
+ * too slow), it is kept and the user is offered to try again — noted in the workspace, so a
1212
+ * reload offers it too. It used to be deleted whatever happened, and what was said was lost (1.20.0). */
1213
+ async writeDictation(file) {
1214
+ const say = (state, extra = {}) => {
1215
+ this.post({ type: 'dictation', state, text: extra.text })
1216
+ if (extra.error) this.say(extra.error, 'dictation')
1217
+ }
1218
+ say('writing')
1219
+ await this.context.workspaceState.update(DICTATION, { file, at: Date.now() })
1220
+ let r
1120
1221
  try {
1121
- const file = await rec.stop()
1122
- const r = await voice.transcribe(serverUrl(), file)
1123
- log.info(`dictation: ${r.duration} s written in ${r.seconds} s (${r.language})`)
1124
- say('idle', r.text ? { text: r.text } : { error: 'Nothing was heard — try again, closer to the microphone.' })
1125
- } catch (error) { say('idle', { error: `Not written: ${error.message}` }) }
1222
+ r = await voice.transcribe(serverUrl(), file)
1223
+ } catch (error) {
1224
+ say('idle', { error: `Not written: ${error.message}.${error.file ? ' The recording is kept — try again when the server answers.' : ''}` })
1225
+ if (!error.file) { await this.context.workspaceState.update(DICTATION, undefined); return }
1226
+ // Not awaited: the question waits for the user, the panel does not.
1227
+ this.offerDictation(error.file, error.message).catch((e) => log.warn('dictation not offered again', e))
1228
+ return
1229
+ }
1230
+ await this.context.workspaceState.update(DICTATION, undefined)
1231
+ log.info(`dictation: ${r.duration} s written in ${r.seconds} s (${r.language})`)
1232
+ say('idle', r.text ? { text: r.text } : { error: 'Nothing was heard — try again, closer to the microphone.' })
1233
+ }
1234
+
1235
+ /** A recording the server did not write: tried again, or let go — never lost without asking. */
1236
+ async offerDictation(file, why) {
1237
+ const again = 'Try again'
1238
+ const drop = 'Discard'
1239
+ const choice = await vscode.window.showWarningMessage(`AIPictury: your dictation was not written (${why}). It is kept on this machine.`, again, drop)
1240
+ if (choice === again) return this.writeDictation(file)
1241
+ if (choice === drop) {
1242
+ voice.discard(file)
1243
+ await this.context.workspaceState.update(DICTATION, undefined)
1244
+ }
1245
+ // Closed without a choice: kept, and offered again after the next reload.
1246
+ }
1247
+
1248
+ /** After a reload: a recording that was being written, or was not, is offered again (once a day at most old). */
1249
+ async resumeDictation() {
1250
+ const left = this.context.workspaceState.get(DICTATION)
1251
+ if (!left?.file) return
1252
+ if (Date.now() - (left.at || 0) > 24 * 3600_000 || !fs.existsSync(left.file)) {
1253
+ voice.discard(left.file)
1254
+ await this.context.workspaceState.update(DICTATION, undefined)
1255
+ return
1256
+ }
1257
+ await this.offerDictation(left.file, 'the window was reloaded before it was')
1126
1258
  }
1127
1259
 
1128
1260
  /** Queued messages, handed to the running agent at its next step — as Claude Code
@@ -1403,6 +1535,7 @@ class AgentPanel {
1403
1535
  return
1404
1536
  }
1405
1537
  if (action === 'fork' || action === 'fork-code') {
1538
+ this.stopDelivering() // followed again below, for the fork
1406
1539
  await this.remember()
1407
1540
  const historyAt = cp ? cp.history : this.history.length
1408
1541
  const forked = { history: this.history.slice(0, historyAt), transcript: this.transcript.slice(0, index),
@@ -1418,6 +1551,7 @@ class AgentPanel {
1418
1551
  if (this.history.length) await this.remember()
1419
1552
  this.post({ type: 'restore', turns: this.transcript, todos: this.todos })
1420
1553
  this.post({ type: 'context', used: agent.estimateTokens(this.history) })
1554
+ this.deliverPending()
1421
1555
  this.post({ type: 'draft', text: turn.text })
1422
1556
  this.say(`${note ? `${note}. ` : ''}forked: a new conversation from before that message — the original is kept in history`)
1423
1557
  return
@@ -1485,7 +1619,6 @@ class AgentPanel {
1485
1619
  signal?.addEventListener('abort', onStop, { once: true })
1486
1620
  this.post({ type: 'approval', id, request })
1487
1621
  this.setBadge('waiting')
1488
- this.notifyAway('AIPictury is waiting for you', `${request.title || request.tool || 'A step'} needs your yes or no`)
1489
1622
  })
1490
1623
  }
1491
1624
 
@@ -1527,6 +1660,59 @@ class AgentPanel {
1527
1660
  })
1528
1661
  }
1529
1662
 
1663
+ /** One row per tool call, updated in place as it runs, kept with its output. */
1664
+ toolRow(event) {
1665
+ const key = event.id || `${event.tool}:${JSON.stringify(event.args)}`
1666
+ const existing = this.transcript.find((t) => t.kind === 'tool' && t.key === key)
1667
+ if (existing) Object.assign(existing, event)
1668
+ else this.transcript.push({ kind: 'tool', key, at: Date.now(), ...event })
1669
+ this.post({ type: 'tool', ...event })
1670
+ }
1671
+
1672
+ /** How keeping a render with the account goes (keeping.js), on its card — a render of this
1673
+ * run, or of an earlier session whose keeping was taken up again after a reload (1.20.0). */
1674
+ keepState(jobId, state) {
1675
+ for (const t of this.transcript) {
1676
+ if (t.kind !== 'tool' || !jobId || t.media?.job !== jobId) continue
1677
+ t.keep = state
1678
+ this.post({ type: 'tool', tool: t.tool, args: t.args, id: t.key, status: 'done', path: t.path, media: t.media, keep: state })
1679
+ }
1680
+ }
1681
+
1682
+ /** Cards of a reopened conversation that said "Compressing for your account" when the window
1683
+ * went: where their keeping stands now, asked of the account (keeping.js). */
1684
+ async refreshKeeping() {
1685
+ const stale = this.transcript.filter((t) => t.kind === 'tool' && t.media?.job && ['compress', 'upload', 'waiting'].includes(t.keep?.stage))
1686
+ for (const t of stale) {
1687
+ const state = await this.registry?.account?.keptState?.(t.media.job).catch(() => null)
1688
+ this.keepState(t.media.job, state || { stage: 'gone' })
1689
+ }
1690
+ }
1691
+
1692
+ /** A picture or clip the agent asked for was still rendering when the window closed (or the
1693
+ * run was cut): followed by its job id and written into the workspace now — whether or not
1694
+ * the run carries on — its row showing how far it is, its result kept for the model (1.20.0). */
1695
+ async deliverPending() {
1696
+ if (this.abort || this.delivering || !serverUrl() || !this.history.some((m) => m.pending)) return
1697
+ const id = this.id
1698
+ const stop = this.delivering = new AbortController()
1699
+ try {
1700
+ const settled = await agent.deliverPending(serverUrl(), this.history, { signal: stop.signal,
1701
+ onTool: (event) => { if (this.id === id) this.toolRow(event) } })
1702
+ if (settled && this.id === id) await this.remember()
1703
+ } catch (error) {
1704
+ log.warn('pending render not delivered', error)
1705
+ } finally { if (this.delivering === stop) this.delivering = null }
1706
+ }
1707
+
1708
+ /** This panel's own delivery stopped (another conversation on screen, a fork, a clear) and
1709
+ * forgotten at once, so the next one can start without waiting for it to wind down. A run
1710
+ * waiting on the same render leaves on its own Stop (mediagen.resume, 1.20.0). */
1711
+ stopDelivering() {
1712
+ this.delivering?.abort()
1713
+ this.delivering = null
1714
+ }
1715
+
1530
1716
  markLive(running) {
1531
1717
  if (!this.id || this.kind !== 'sidebar') return
1532
1718
  this.context.workspaceState.update(LIVE, { id: this.id, running, at: Date.now() })
@@ -1572,12 +1758,7 @@ class AgentPanel {
1572
1758
  onThinking: (delta) => this.post({ type: 'thinking', text: delta }),
1573
1759
  onSpeed: (speed) => this.post({ type: 'speed', ...speed }),
1574
1760
  onTool: (event) => {
1575
- // One row per call, updated in place as it runs, kept with its output.
1576
- const key = event.id || `${event.tool}:${JSON.stringify(event.args)}`
1577
- const existing = this.transcript.find((t) => t.kind === 'tool' && t.key === key)
1578
- if (existing) Object.assign(existing, event)
1579
- else this.transcript.push({ kind: 'tool', key, at: Date.now(), ...event })
1580
- this.post({ type: 'tool', ...event })
1761
+ this.toolRow(event)
1581
1762
  if (event.tasks) this.post({ type: 'tasks', items: event.tasks })
1582
1763
  if (event.status === 'done') {
1583
1764
  this.rememberSoon()
@@ -1603,8 +1784,12 @@ class AgentPanel {
1603
1784
  onMonitor: (task, lines, info) => this.onMonitorLines(task, lines, info),
1604
1785
  schedule: this.schedulerHooks(),
1605
1786
  onHelperDone: (h, text) => this.onHelperDone(h, text),
1606
- // Saved at every step, so a crash, a reload or closing VS Code loses nothing.
1607
- onMessages: (messages) => { this.history = messages.map(withoutImages); this.markLive(true); this.rememberSoon() },
1787
+ // Saved at every step, so a crash, a reload or closing VS Code loses nothing; a render's job id
1788
+ // at once (`now`), as it is what delivers the render if the window goes in the next second.
1789
+ onMessages: (messages, { now = false } = {}) => {
1790
+ this.history = messages.map(withoutImages); this.markLive(true)
1791
+ if (now) this.remember().catch(() => {}); else this.rememberSoon()
1792
+ },
1608
1793
  onDecision: (picks) => decisions.push(...picks),
1609
1794
  onWaiting: (event) => this.post({ type: 'waiting', seconds: event.waiting, phase: event.phase }),
1610
1795
  onTurn: ({ text: said, thinking, meta, calls }) => {
@@ -1647,10 +1832,10 @@ class AgentPanel {
1647
1832
  this.post({ type: 'decisions', items: decisions })
1648
1833
  }
1649
1834
  stopped = !!result.stopped
1650
- if (!stopped) this.notifyAway(result.error ? 'AIPictury stopped on an error' : 'AIPictury is done',
1651
- result.error ? String(result.error).split('\n')[0] : (this.title || 'The run finished'))
1835
+ this.ended = { how: stopped ? 'stopped' : result.error ? 'error' : 'done', why: result.error ? String(result.error).split('\n')[0] : undefined }
1652
1836
  } catch (error) {
1653
1837
  stopped = error.name === 'AbortError'
1838
+ this.ended = { how: stopped ? 'stopped' : 'error', why: stopped ? undefined : error.message }
1654
1839
  if (!stopped) log.error('run failed', error)
1655
1840
  this.post({ type: 'end', stopped, error: stopped ? undefined : error.message })
1656
1841
  } finally {
@@ -1693,8 +1878,15 @@ class AgentPanel {
1693
1878
  if (live.running && Date.now() - live.at < CONTINUE_WINDOW && config().get('continueAfterReload', true)) {
1694
1879
  this.say('the window was reloaded while the agent was working — continuing from its last step')
1695
1880
  this.transcript.push({ kind: 'user', text: '(continuing after the window reloaded)', at: Date.now(), checkpoint: this.newCheckpoint() })
1696
- await this.start([{ role: 'user', content: '[The editor window was reloaded while you were working. Nothing you '
1697
- + 'did is lost — the conversation above is complete. Continue the task from where you left off.]' }])
1881
+ // Said as it is: every finished step is above and a render still going is delivered before the
1882
+ // next turn (deliverPending), but a call the reload cut mid-way has no result. "Nothing is
1883
+ // lost" had the model take an unfinished step for done, or do it again blindly (1.20.0).
1884
+ const cut = cutCalls(this.history)
1885
+ await this.start([{ role: 'user', content: '[The editor window was reloaded while you were working. Every step '
1886
+ + 'that finished is in the conversation above, with its result.'
1887
+ + (cut.length ? ` The reload cut off ${cut.join(', ')} before it returned: it has no result, and may or may not `
1888
+ + 'have taken effect — check before doing it again.' : '')
1889
+ + ' Continue the task from where you left off.]' }])
1698
1890
  }
1699
1891
  }
1700
1892
 
@@ -1764,6 +1956,14 @@ class AgentPanel {
1764
1956
  }
1765
1957
  }
1766
1958
 
1959
+ /** The tool calls of the last step that have no result: what a reload cut off mid-way. */
1960
+ function cutCalls(history) {
1961
+ const at = history.findLastIndex((m) => m.role === 'assistant')
1962
+ if (at < 0) return []
1963
+ const answered = history.slice(at + 1).filter((m) => m.role === 'tool').length
1964
+ return (history[at].tool_calls || []).slice(answered).map((c) => c.function?.name).filter(Boolean)
1965
+ }
1966
+
1767
1967
  /** Pictures are sent once; the kept conversation notes where they were. */
1768
1968
  function withoutImages(m) {
1769
1969
  if (!m.images) return m
@@ -1819,6 +2019,30 @@ class Registry {
1819
2019
 
1820
2020
  all() { return [this.sidebar, ...this.tabs] }
1821
2021
 
2022
+ /** After a reload or a crash: this machine's listings of this project's shared conversations
2023
+ * that no panel hosts again are dropped — aipictury.com offered links nobody was at (1.20.0) —
2024
+ * and any other listing of this machine that its window no longer keeps fresh
2025
+ * (cloud.remoteLive): a window still hosting it lists it again at its next beat. Another
2026
+ * machine's listings are its own to drop, and the web's to hide when stale: a window of an
2027
+ * older version there hosts without a beat, and its live link vanished from aipictury.com
2028
+ * (1.20.0). The links themselves stay kept: reopening the conversation hosts and lists it again. */
2029
+ async pruneRemote() {
2030
+ const cloud = this.account?.cloud
2031
+ if (!cloud?.listRemote || !this.account.signedIn?.()) return 0
2032
+ const hosted = new Set(this.all().map((p) => p?.remote?.id).filter(Boolean))
2033
+ const store = sessions.storeFor(workspaceRoot()?.fsPath)
2034
+ const here = require('os').hostname().slice(0, 80)
2035
+ let dropped = 0
2036
+ for (const r of await cloud.listRemote()) {
2037
+ if (r.device !== here || hosted.has(r.id)) continue
2038
+ const leftHere = r.conversation && store?.get(r.conversation)
2039
+ if (!leftHere && remoteLive(r)) continue
2040
+ await cloud.dropRemote(r.id).catch(() => {})
2041
+ dropped += 1
2042
+ }
2043
+ return dropped
2044
+ }
2045
+
1822
2046
  /** Asked from the web: the panel with this conversation shares it by remote control — opened
1823
2047
  * first when it is this project's but not on screen. Another project's: some other window's. */
1824
2048
  async shareAsked(conversation) {
@@ -1841,11 +2065,11 @@ class Registry {
1841
2065
  active() { return this.last?.host ? this.last : this.sidebar }
1842
2066
 
1843
2067
  /** A new conversation in an editor tab — several can run side by side. */
1844
- openTab(existing) {
2068
+ openTab(existing, { slot } = {}) {
1845
2069
  const host = existing || vscode.window.createWebviewPanel('aipictury.agentTab', 'AIPictury Agent',
1846
2070
  { viewColumn: vscode.ViewColumn.Beside, preserveFocus: false }, { enableScripts: true, retainContextWhenHidden: true })
1847
2071
  host.iconPath = vscode.Uri.joinPath(this.context.extensionUri, 'media', 'icon.svg')
1848
- const panel = new AgentPanel(this.context, { kind: 'tab', registry: this })
2072
+ const panel = new AgentPanel(this.context, { kind: 'tab', registry: this, slot })
1849
2073
  panel.attach(host)
1850
2074
  this.tabs.add(panel)
1851
2075
  this.last = panel
@@ -2325,6 +2549,16 @@ function activate(context) {
2325
2549
  store: () => sessions.storeFor(workspaceRoot()?.fsPath), server: serverUrl })
2326
2550
  // The AIPictury account: needed to use the extension; what is yours is kept with it.
2327
2551
  registry.account = require('./accountUi').register(context, { registry, sessionsView: registry.sessionsView, serverUrl })
2552
+ // Remote-control links this window does not host again, unlisted once the panels are back.
2553
+ const pruning = setTimeout(() => registry.pruneRemote().catch((error) => log.warn('remote listings not pruned', error)), 30_000)
2554
+ add({ dispose: () => clearTimeout(pruning) })
2555
+ // Problem reports to send that did not reach the server (feedback.js's outbox): sent once it
2556
+ // answers — a while after the start, then every quarter of an hour (1.20.0).
2557
+ const sendReports = () => feedback.sendWaiting(serverUrl(), dialogs.sendReport)
2558
+ .then((n) => { if (n) log.info(`${n} problem report(s) sent to the server`) }).catch((error) => log.warn('problem reports not sent', error))
2559
+ const reporting = setTimeout(sendReports, 45_000)
2560
+ const reportingAgain = setInterval(sendReports, 15 * 60_000)
2561
+ add({ dispose: () => { clearTimeout(reporting); clearInterval(reportingAgain) } })
2328
2562
  add(vscode.commands.registerCommand('aipictury.signIn', () => registry.account.signIn()))
2329
2563
  add(vscode.commands.registerCommand('aipictury.signOut', () => registry.account.signOut()))
2330
2564
  add(vscode.commands.registerCommand('aipictury.account', () => active().openAccount()))
@@ -2333,12 +2567,13 @@ function activate(context) {
2333
2567
  resolveWebviewView(view) {
2334
2568
  panel.attach(view)
2335
2569
  panel.resumeAfterReload().catch(() => {})
2570
+ panel.resumeDictation().catch((error) => log.warn('dictation not offered again', error))
2336
2571
  },
2337
2572
  }, { webviewOptions: { retainContextWhenHidden: true } }))
2338
2573
  // Agent tabs come back after a reload, with their conversations.
2339
2574
  add(vscode.window.registerWebviewPanelSerializer('aipictury.agentTab', {
2340
2575
  async deserializeWebviewPanel(host, state) {
2341
- const tab = registry.openTab(host)
2576
+ const tab = registry.openTab(host, { slot: state?.slot })
2342
2577
  if (state?.id) await tab.open(state.id, { focus: false })
2343
2578
  },
2344
2579
  }))
@@ -2477,12 +2712,16 @@ function activate(context) {
2477
2712
  }
2478
2713
 
2479
2714
  // Anything the agent left running — a dev server, MCP servers — goes when VS Code does,
2480
- // and every conversation on screen is saved first.
2715
+ // and every conversation on screen is saved first — then what is still to be pushed to the
2716
+ // account is pushed (its saves wait a few seconds to be sent together, and closing dropped
2717
+ // them: 1.20.0), within the few seconds VS Code gives an extension to stop.
2481
2718
  async function deactivate() {
2482
2719
  agent.stopBackground()
2483
2720
  mcp.closeAll()
2484
2721
  await require('./browser').close()
2485
2722
  await Promise.all((registry?.all() || []).map((p) => p.remember().catch(() => {})))
2723
+ const cloud = registry?.account?.cloud
2724
+ if (cloud?.flush) await Promise.race([cloud.flush().catch(() => {}), new Promise((r) => setTimeout(r, FLUSH_BUDGET).unref?.())])
2486
2725
  }
2487
2726
 
2488
- module.exports = { syncCommandEnv, moveConversationsToDisk, rewindQuestion, activate, deactivate, undoOldThinkingSwitch, adoptNewDefaultModel, fuzzyScore, toMarkdown, resolveMentions, mentionCandidates, INIT_PROMPT, REVIEW_PROMPT }
2727
+ module.exports = { syncCommandEnv, moveConversationsToDisk, rewindQuestion, activate, deactivate, undoOldThinkingSwitch, adoptNewDefaultModel, fuzzyScore, toMarkdown, resolveMentions, mentionCandidates, cutCalls, Registry, INIT_PROMPT, REVIEW_PROMPT }