@davait/aipictury 1.9.2

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.
Files changed (54) hide show
  1. package/LICENSE.md +77 -0
  2. package/README.md +39 -0
  3. package/ext/about.js +209 -0
  4. package/ext/account.js +242 -0
  5. package/ext/accountUi.js +287 -0
  6. package/ext/agent.js +1929 -0
  7. package/ext/browser.js +263 -0
  8. package/ext/cloud.js +385 -0
  9. package/ext/context.js +241 -0
  10. package/ext/deps.js +142 -0
  11. package/ext/dialogs.js +803 -0
  12. package/ext/diff.js +150 -0
  13. package/ext/extension.js +2483 -0
  14. package/ext/feedback.js +55 -0
  15. package/ext/files.js +565 -0
  16. package/ext/firestore.js +131 -0
  17. package/ext/gpuRun.js +77 -0
  18. package/ext/gpuServer.js +76 -0
  19. package/ext/hooks.js +153 -0
  20. package/ext/log.js +29 -0
  21. package/ext/lsp.js +122 -0
  22. package/ext/mcp.js +469 -0
  23. package/ext/mcpAuth.js +128 -0
  24. package/ext/media/vendor/firebase-config.json +9 -0
  25. package/ext/media/vendor/signin.js +1 -0
  26. package/ext/mediagen.js +174 -0
  27. package/ext/memory.js +117 -0
  28. package/ext/notebook.js +82 -0
  29. package/ext/package.json +1234 -0
  30. package/ext/paths.js +50 -0
  31. package/ext/permissions.js +229 -0
  32. package/ext/plans.js +110 -0
  33. package/ext/plugins.js +243 -0
  34. package/ext/prefs.js +157 -0
  35. package/ext/remote.js +170 -0
  36. package/ext/review.js +126 -0
  37. package/ext/sandbox.js +79 -0
  38. package/ext/scheduler.js +87 -0
  39. package/ext/sessions.js +228 -0
  40. package/ext/sessionsView.js +449 -0
  41. package/ext/shell.js +357 -0
  42. package/ext/storage.js +80 -0
  43. package/ext/tasklist.js +112 -0
  44. package/ext/text.js +31 -0
  45. package/ext/tools.js +781 -0
  46. package/ext/usage.js +77 -0
  47. package/ext/voice.js +135 -0
  48. package/ext/workbench.js +162 -0
  49. package/ext/workflow.js +51 -0
  50. package/ext/worktree.js +83 -0
  51. package/main.js +758 -0
  52. package/package.json +43 -0
  53. package/terminal.js +124 -0
  54. package/vscode-shim.js +231 -0
@@ -0,0 +1,228 @@
1
+ 'use strict'
2
+ // Conversations kept on disk, one file each, per project:
3
+ //
4
+ // ~/.aipictury/projects/<project>/index.json what the lists show (no history)
5
+ // ~/.aipictury/projects/<project>/groups.json named groups of conversations
6
+ // ~/.aipictury/projects/<project>/sessions/<id>.json the whole conversation
7
+ // ~/.aipictury/projects/<project>/sessions/<id>.checkpoints.json
8
+ //
9
+ // Not VS Code's workspace state: every window of the same folder — and the `aipictury` CLI —
10
+ // sees the same conversations, and a change in one reaches the others through a watch.
11
+ // AIPICTURY_HOME moves the whole tree (the tests use a scratch one).
12
+ const fs = require('fs')
13
+ const os = require('os')
14
+ const path = require('path')
15
+ const crypto = require('crypto')
16
+
17
+ const home = () => process.env.AIPICTURY_HOME || path.join(os.homedir(), '.aipictury')
18
+
19
+ /** A folder's own directory name: readable, and unique per path. */
20
+ function projectKey(folder) {
21
+ const base = path.basename(folder || 'no-folder').replace(/[^\w.-]+/g, '-').slice(0, 40) || 'root'
22
+ const hash = crypto.createHash('sha1').update(path.resolve(folder || '/')).digest('hex').slice(0, 10)
23
+ return `${base}-${hash}`
24
+ }
25
+
26
+ /** Written whole or not at all: a reader never sees half a file. */
27
+ function writeAtomic(file, text) {
28
+ fs.mkdirSync(path.dirname(file), { recursive: true })
29
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`
30
+ fs.writeFileSync(tmp, text)
31
+ fs.renameSync(tmp, file)
32
+ }
33
+
34
+ function readJson(file, fallback) {
35
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return fallback }
36
+ }
37
+
38
+ // What the lists keep of a conversation; everything else is only in its own file.
39
+ const META = ['id', 'title', 'renamed', 'created', 'updated', 'server', 'model', 'status', 'group',
40
+ 'archived', 'unread', 'mode', 'questions']
41
+
42
+ // Who hears of every change to a conversation (the cloud copy): { type: 'save' | 'patch' |
43
+ // 'remove', folder, id, entry? }. One place, so every path — the panel, the Sessions view, the
44
+ // CLI — keeps the copy in step.
45
+ const writeListeners = new Set()
46
+ function onWrite(fn) { writeListeners.add(fn); return { dispose: () => writeListeners.delete(fn) } }
47
+ function told(event) { for (const fn of writeListeners) { try { fn(event) } catch { /* a listener's own problem */ } } }
48
+
49
+ class SessionStore {
50
+ constructor(folder) {
51
+ this.folder = folder
52
+ this.dir = path.join(home(), 'projects', projectKey(folder))
53
+ this.sessions = path.join(this.dir, 'sessions')
54
+ this.indexFile = path.join(this.dir, 'index.json')
55
+ this.groupsFile = path.join(this.dir, 'groups.json')
56
+ this.listeners = new Set()
57
+ this.watcher = null
58
+ }
59
+
60
+ // ---- the list ----
61
+
62
+ /** Every conversation's summary, newest first. */
63
+ list() {
64
+ const index = readJson(this.indexFile, [])
65
+ return (Array.isArray(index) ? index : []).sort((a, b) => (b.updated || 0) - (a.updated || 0))
66
+ }
67
+
68
+ writeIndex(entries) {
69
+ writeAtomic(this.indexFile, JSON.stringify(entries, null, 1))
70
+ this.changed()
71
+ }
72
+
73
+ /** One conversation whole, with what the list knows of it on top (a rename, a group). */
74
+ get(id) {
75
+ const entry = readJson(this.file(id), null)
76
+ if (!entry) return null
77
+ const meta = this.list().find((m) => m.id === id) || {}
78
+ return { ...entry, ...pick(meta, META) }
79
+ }
80
+
81
+ file(id) { return path.join(this.sessions, `${String(id).replace(/[^\w-]/g, '')}.json`) }
82
+ checkpointsFile(id) { return path.join(this.sessions, `${String(id).replace(/[^\w-]/g, '')}.checkpoints.json`) }
83
+
84
+ /** Saves a conversation. What only the lists set — its group, archived, unread — is kept. */
85
+ save(entry) {
86
+ if (Array.isArray(entry.history)) entry = { ...entry, questions: entry.history.filter((m) => m.role === 'user').length }
87
+ writeAtomic(this.file(entry.id), JSON.stringify(entry))
88
+ const index = this.list()
89
+ const old = index.find((m) => m.id === entry.id) || {}
90
+ const meta = { ...old, ...pick(entry, META) }
91
+ for (const key of ['group', 'archived', 'unread']) if (old[key] !== undefined && entry[key] === undefined) meta[key] = old[key]
92
+ this.writeIndex([meta, ...index.filter((m) => m.id !== entry.id)])
93
+ told({ type: 'save', folder: this.folder, id: entry.id, entry: { ...entry, group: meta.group, archived: meta.archived } })
94
+ }
95
+
96
+ /** Changes what the lists show of a conversation: title, group, archived, unread, status. */
97
+ patch(id, fields) {
98
+ const index = this.list()
99
+ const at = index.findIndex((m) => m.id === id)
100
+ if (at < 0) return false
101
+ index[at] = { ...index[at], ...fields }
102
+ for (const [key, value] of Object.entries(fields)) if (value === undefined) delete index[at][key]
103
+ this.writeIndex(index)
104
+ if ('title' in fields || 'renamed' in fields) {
105
+ const entry = readJson(this.file(id), null)
106
+ if (entry) writeAtomic(this.file(id), JSON.stringify({ ...entry, title: index[at].title, renamed: index[at].renamed }))
107
+ }
108
+ // What the cloud keeps of a conversation's summary: its title, group, archived.
109
+ if (['title', 'renamed', 'group', 'archived'].some((k) => k in fields)) {
110
+ const entry = readJson(this.file(id), null)
111
+ if (entry) told({ type: 'patch', folder: this.folder, id, entry: { ...entry, title: index[at].title, renamed: index[at].renamed, group: index[at].group, archived: index[at].archived } })
112
+ }
113
+ return true
114
+ }
115
+
116
+ remove(id) {
117
+ for (const f of [this.file(id), this.checkpointsFile(id)]) { try { fs.unlinkSync(f) } catch { /* gone */ } }
118
+ this.writeIndex(this.list().filter((m) => m.id !== id))
119
+ told({ type: 'remove', folder: this.folder, id })
120
+ }
121
+
122
+ // ---- checkpoints ----
123
+
124
+ saveCheckpoints(id, checkpoints) {
125
+ try { writeAtomic(this.checkpointsFile(id), JSON.stringify(checkpoints)) } catch { /* a convenience */ }
126
+ }
127
+
128
+ loadCheckpoints(id) { return readJson(this.checkpointsFile(id), []) }
129
+
130
+ // ---- groups ----
131
+
132
+ groups() { return readJson(this.groupsFile, []) }
133
+
134
+ createGroup(name) {
135
+ const clean = String(name || '').trim().slice(0, 60)
136
+ if (!clean) throw new Error('a group needs a name')
137
+ const groups = this.groups()
138
+ const found = groups.find((g) => g.name.toLowerCase() === clean.toLowerCase())
139
+ if (found) return found
140
+ const group = { id: `g${Date.now().toString(36)}${crypto.randomBytes(2).toString('hex')}`, name: clean }
141
+ writeAtomic(this.groupsFile, JSON.stringify([...groups, group], null, 1))
142
+ this.changed()
143
+ return group
144
+ }
145
+
146
+ renameGroup(id, name) {
147
+ const clean = String(name || '').trim().slice(0, 60)
148
+ if (!clean) throw new Error('a group needs a name')
149
+ writeAtomic(this.groupsFile, JSON.stringify(this.groups().map((g) => (g.id === id ? { ...g, name: clean } : g)), null, 1))
150
+ this.changed()
151
+ }
152
+
153
+ /** Deleting a group only ungroups its conversations. */
154
+ deleteGroup(id) {
155
+ writeAtomic(this.groupsFile, JSON.stringify(this.groups().filter((g) => g.id !== id), null, 1))
156
+ const index = this.list()
157
+ for (const m of index) if (m.group === id) delete m.group
158
+ this.writeIndex(index)
159
+ }
160
+
161
+ // ---- archive ----
162
+
163
+ /** Archives what has had no activity for `days`, unless it is open, unread or grouped. */
164
+ archiveInactive(days, { open = new Set(), now = Date.now() } = {}) {
165
+ if (!days) return 0
166
+ const index = this.list()
167
+ let count = 0
168
+ for (const m of index) {
169
+ if (m.archived || m.unread || m.group || open.has(m.id)) continue
170
+ if (now - (m.updated || m.created || now) > days * 86_400_000) { m.archived = true; count += 1 }
171
+ }
172
+ if (count) this.writeIndex(index)
173
+ return count
174
+ }
175
+
176
+ // ---- moving in from VS Code's workspace state (the store before 1.7) ----
177
+
178
+ /** Copies conversations kept the old way; those already on disk are left as they are. */
179
+ migrate(entries, oldCheckpointsDir) {
180
+ if (!Array.isArray(entries) || !entries.length) return 0
181
+ const have = new Set(this.list().map((m) => m.id))
182
+ let moved = 0
183
+ for (const entry of [...entries].reverse()) {
184
+ if (!entry?.id || have.has(entry.id)) continue
185
+ this.save(entry)
186
+ if (oldCheckpointsDir) {
187
+ const old = readJson(path.join(oldCheckpointsDir, `${entry.id}.json`), null)
188
+ if (old) this.saveCheckpoints(entry.id, old)
189
+ }
190
+ moved += 1
191
+ }
192
+ return moved
193
+ }
194
+
195
+ // ---- changes from other windows ----
196
+
197
+ onChange(listener) {
198
+ this.listeners.add(listener)
199
+ if (!this.watcher) {
200
+ fs.mkdirSync(this.dir, { recursive: true })
201
+ let timer
202
+ try {
203
+ this.watcher = fs.watch(this.dir, () => { clearTimeout(timer); timer = setTimeout(() => this.emit(), 150) })
204
+ } catch { /* no watching on this system: each window still sees its own changes */ }
205
+ }
206
+ return { dispose: () => this.listeners.delete(listener) }
207
+ }
208
+
209
+ changed() { this.emit() }
210
+ emit() { for (const l of this.listeners) { try { l() } catch { /* a listener's own problem */ } } }
211
+ dispose() { this.watcher?.close(); this.watcher = null; this.listeners.clear() }
212
+ }
213
+
214
+ function pick(object, keys) {
215
+ const out = {}
216
+ for (const k of keys) if (object[k] !== undefined) out[k] = object[k]
217
+ return out
218
+ }
219
+
220
+ const stores = new Map()
221
+ /** The store of a folder (one per folder, shared by every panel of this window). */
222
+ function storeFor(folder) {
223
+ const key = `${home()}::${path.resolve(folder || '/')}`
224
+ if (!stores.has(key)) stores.set(key, new SessionStore(folder))
225
+ return stores.get(key)
226
+ }
227
+
228
+ module.exports = { SessionStore, storeFor, projectKey, home, onWrite }
@@ -0,0 +1,449 @@
1
+ 'use strict'
2
+ // The Sessions view in the Activity Bar: every conversation of this project, in its groups,
3
+ // with what it is doing — needs you, working, finished, unread — live. Filters and a search
4
+ // (which lists everything flat), archive, and the web app's chats, which can be carried on
5
+ // here. The list is the store on disk, so every window shows the same one.
6
+ const vscode = require('vscode')
7
+ const workbench = require('./workbench')
8
+ const log = require('./log')
9
+
10
+ const FILTER_KEY = 'aipictury.sessionsFilter'
11
+ const STATUSES = [
12
+ { id: 'waiting', label: 'Needs input', icon: 'bell-dot', color: 'list.warningForeground' },
13
+ { id: 'working', label: 'Working', icon: 'sync~spin' },
14
+ { id: 'unread', label: 'Unread', icon: 'circle-filled', color: 'notificationsInfoIcon.foreground' },
15
+ { id: 'done', label: 'Completed', icon: 'pass' },
16
+ ]
17
+ const STATUS = Object.fromEntries(STATUSES.map((s) => [s.id, s]))
18
+
19
+ /** What a conversation is doing: from its open panel when there is one, else from the store. */
20
+ function statusOf(meta, panel) {
21
+ if (panel) {
22
+ if (panel.pendingAsk || panel.approvals?.size || panel.pendingPlan) return 'waiting'
23
+ if (panel.abort) return 'working'
24
+ }
25
+ return meta.unread ? 'unread' : 'done'
26
+ }
27
+
28
+ function ago(when) {
29
+ const s = Math.max(0, Math.round((Date.now() - (when || 0)) / 1000))
30
+ if (s < 60) return 'just now'
31
+ if (s < 3600) return `${Math.round(s / 60)} min ago`
32
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`
33
+ return `${Math.round(s / 86400)} d ago`
34
+ }
35
+
36
+ class SessionsView {
37
+ /** registry: the open panels; store(): this workspace's conversations; server(): its URL. */
38
+ constructor(context, { registry, store, server, fetchImpl = fetch }) {
39
+ this.context = context
40
+ this.registry = registry
41
+ this.store = store
42
+ this.server = server
43
+ this.fetch = fetchImpl
44
+ this.changed = new vscode.EventEmitter()
45
+ this.onDidChangeTreeData = this.changed.event
46
+ this.webChats = null // loaded when the Web section is opened
47
+ this.cloud = null // the account's copy (accountUi.js), when signed in
48
+ this.timer = null
49
+ }
50
+
51
+ get filter() {
52
+ return { active: false, statuses: [], search: '', ...(this.context.workspaceState.get(FILTER_KEY) || {}) }
53
+ }
54
+
55
+ async setFilter(change) {
56
+ await this.context.workspaceState.update(FILTER_KEY, { ...this.filter, ...change })
57
+ this.describe()
58
+ this.refresh()
59
+ }
60
+
61
+ /** Says in the view what narrows it, so an empty list is never a mystery. */
62
+ describe() {
63
+ if (!this.view) return
64
+ const f = this.filter
65
+ const parts = [f.search && `matching “${f.search}”`, f.active && 'open only',
66
+ f.statuses.length && f.statuses.map((s) => STATUS[s]?.label).join(', ')].filter(Boolean)
67
+ this.view.message = parts.length ? `Showing ${parts.join(' · ')}` : undefined
68
+ vscode.commands.executeCommand('setContext', 'aipictury.sessionsFiltered', parts.length > 0)
69
+ }
70
+
71
+ /** Redraws soon — many changes in a row are one redraw. */
72
+ refresh() {
73
+ clearTimeout(this.timer)
74
+ this.timer = setTimeout(() => this.changed.fire(), 80)
75
+ }
76
+
77
+ /** The panel holding a conversation, if any — shown or not: what it is doing comes from it. */
78
+ livePanel(id) { return this.registry.all().find((p) => p.id === id) }
79
+
80
+ sessions() {
81
+ const f = this.filter
82
+ const needle = f.search.trim().toLowerCase()
83
+ return this.store().list().map((meta) => ({ meta, panel: this.livePanel(meta.id) }))
84
+ .map((s) => ({ ...s, status: statusOf(s.meta, s.panel) }))
85
+ .filter((s) => !needle || `${s.meta.title} ${s.meta.model || ''}`.toLowerCase().includes(needle))
86
+ .filter((s) => !f.active || s.panel?.host)
87
+ .filter((s) => !f.statuses.length || f.statuses.includes(s.status))
88
+ }
89
+
90
+ getChildren(node) {
91
+ const store = this.store()
92
+ if (!node) {
93
+ const all = this.sessions()
94
+ // A search lists every match flat, archived ones too: it is how one is found again.
95
+ if (this.filter.search.trim()) return all.map((s) => ({ kind: 'session', ...s }))
96
+ const groups = store.groups()
97
+ const live = all.filter((s) => !s.meta.archived)
98
+ const out = groups.map((g) => ({ kind: 'group', group: g, items: live.filter((s) => s.meta.group === g.id) }))
99
+ out.push(...live.filter((s) => !s.meta.group || !groups.some((g) => g.id === s.meta.group)).map((s) => ({ kind: 'session', ...s })))
100
+ const archived = all.filter((s) => s.meta.archived)
101
+ if (archived.length) out.push({ kind: 'archived', items: archived })
102
+ if (this.cloud) out.push({ kind: 'cloud' })
103
+ out.push({ kind: 'web' })
104
+ return out
105
+ }
106
+ if (node.kind === 'group' || node.kind === 'archived') return node.items.map((s) => ({ kind: 'session', ...s }))
107
+ if (node.kind === 'web') return this.loadWeb()
108
+ if (node.kind === 'cloud') return this.loadCloud()
109
+ return []
110
+ }
111
+
112
+ /** Signed in: the account's copy; signed out (null): the group and its list go. */
113
+ setCloud(cloud) {
114
+ this.cloud = cloud
115
+ this.webChats = null // the last account's list goes with it
116
+ this.refresh()
117
+ }
118
+
119
+ /** This project's conversations kept with the account but not on this machine. */
120
+ async loadCloud() {
121
+ const cloud = this.cloud
122
+ const folder = this.store().folder
123
+ if (!cloud || !folder) return []
124
+ try {
125
+ const local = new Set(this.store().list().map((m) => m.id))
126
+ const remote = (await cloud.listConversations(folder)).filter((c) => !local.has(c.id))
127
+ if (this.cloud !== cloud) return [] // signed out meanwhile: nothing shown
128
+ if (!remote.length) return [{ kind: 'note', label: 'Nothing here that is not on this machine' }]
129
+ return remote.map((c) => ({ kind: 'cloudChat', chat: c }))
130
+ } catch (error) {
131
+ return [{ kind: 'note', label: `Your account cannot be reached (${error.message})` }]
132
+ }
133
+ }
134
+
135
+ async openCloud(node) {
136
+ const cloud = this.cloud
137
+ if (!cloud) return null
138
+ const entry = await cloud.pullConversation(this.store().folder, node.chat.id)
139
+ if (this.cloud !== cloud) return null
140
+ this.store().save({ ...entry, server: this.server() })
141
+ log.info(`conversation ${entry.id} brought from the account`)
142
+ return this.open({ meta: { id: entry.id } })
143
+ }
144
+
145
+ async loadWeb() {
146
+ // Signed in, the web app's chats are the account's (its project "web"), wherever they were made.
147
+ if (this.cloud) {
148
+ const cloud = this.cloud
149
+ try {
150
+ const chats = (await cloud.listProject('web')).map((c) => ({ id: c.id, title: c.title, created_at: (c.updated || 0) / 1000, fromAccount: true }))
151
+ if (this.cloud !== cloud) return []
152
+ this.webChats = chats
153
+ if (!chats.length) return [{ kind: 'note', label: 'No chats at aipictury.com yet' }]
154
+ return chats.map((chat) => ({ kind: 'webChat', chat }))
155
+ } catch (error) {
156
+ return [{ kind: 'note', label: `Your account cannot be reached (${error.message})` }]
157
+ }
158
+ }
159
+ try {
160
+ const controller = new AbortController()
161
+ const timer = setTimeout(() => controller.abort(), 8000)
162
+ const response = await this.fetch(`${this.server()}/api/assistant/chats`, { signal: controller.signal })
163
+ clearTimeout(timer)
164
+ const { chats = [] } = response.ok ? await response.json() : {}
165
+ this.webChats = chats
166
+ if (!chats.length) return [{ kind: 'note', label: 'No chats in the web app yet' }]
167
+ return chats.map((chat) => ({ kind: 'webChat', chat }))
168
+ } catch (error) {
169
+ return [{ kind: 'note', label: `The server cannot be reached (${error.name === 'AbortError' ? 'no answer' : error.message})` }]
170
+ }
171
+ }
172
+
173
+ getTreeItem(node) {
174
+ const T = vscode.TreeItemCollapsibleState
175
+ if (node.kind === 'group') {
176
+ const item = new vscode.TreeItem(node.group.name, T.Expanded)
177
+ item.id = `group:${node.group.id}`
178
+ item.contextValue = 'group'
179
+ item.iconPath = new vscode.ThemeIcon('folder')
180
+ item.description = `${node.items.length}`
181
+ return item
182
+ }
183
+ if (node.kind === 'archived') {
184
+ const item = new vscode.TreeItem('Archived', T.Collapsed)
185
+ item.id = 'archived'
186
+ item.contextValue = 'archivedRoot'
187
+ item.iconPath = new vscode.ThemeIcon('archive')
188
+ item.description = `${node.items.length}`
189
+ return item
190
+ }
191
+ if (node.kind === 'web') {
192
+ const item = new vscode.TreeItem('Web app chats', T.Collapsed)
193
+ item.id = 'web'
194
+ item.contextValue = 'webRoot'
195
+ item.iconPath = new vscode.ThemeIcon('globe')
196
+ item.tooltip = 'Chats from the AIPictury web app — open one to carry it on here'
197
+ return item
198
+ }
199
+ if (node.kind === 'cloud') {
200
+ const item = new vscode.TreeItem('In the cloud', T.Collapsed)
201
+ item.id = 'cloud'
202
+ item.contextValue = 'cloudRoot'
203
+ item.iconPath = new vscode.ThemeIcon('cloud')
204
+ item.tooltip = 'Conversations of this project kept with your AIPictury account, from another machine or the CLI'
205
+ return item
206
+ }
207
+ if (node.kind === 'cloudChat') {
208
+ const item = new vscode.TreeItem(node.chat.title || 'Untitled', T.None)
209
+ item.id = `cloud:${node.chat.id}`
210
+ item.contextValue = 'cloudChat'
211
+ item.iconPath = new vscode.ThemeIcon('cloud-download')
212
+ item.description = `${ago(node.chat.updated || 0)}${node.chat.origin === 'cli' ? ' · CLI' : ''}`
213
+ item.tooltip = 'Open it here: it is brought to this machine, and stays in step with your account'
214
+ item.command = { command: 'aipictury.sessions.openCloud', title: 'Open', arguments: [node] }
215
+ return item
216
+ }
217
+ if (node.kind === 'note') {
218
+ const item = new vscode.TreeItem(node.label, T.None)
219
+ item.iconPath = new vscode.ThemeIcon('info')
220
+ return item
221
+ }
222
+ if (node.kind === 'webChat') {
223
+ const item = new vscode.TreeItem(node.chat.title || 'Untitled', T.None)
224
+ item.id = `web:${node.chat.id}`
225
+ item.contextValue = 'webChat'
226
+ item.iconPath = new vscode.ThemeIcon('comment')
227
+ item.description = ago((node.chat.created_at || 0) * 1000)
228
+ item.tooltip = 'Carry this chat on in VS Code (a copy: the web chat stays as it is)'
229
+ item.command = { command: 'aipictury.sessions.continueWeb', title: 'Continue here', arguments: [node] }
230
+ return item
231
+ }
232
+ const { meta, status, panel } = node
233
+ const look = STATUS[status]
234
+ const item = new vscode.TreeItem(meta.title || 'New conversation', T.None)
235
+ item.id = `session:${meta.id}`
236
+ item.contextValue = `session${meta.archived ? '.archived' : ''}${meta.unread ? '.unread' : ''}${meta.group ? '.grouped' : ''}`
237
+ item.iconPath = new vscode.ThemeIcon(look.icon, look.color ? new vscode.ThemeColor(look.color) : undefined)
238
+ item.description = `${panel?.host ? '● ' : ''}${ago(meta.updated)}`
239
+ item.tooltip = new vscode.MarkdownString(`**${meta.title}**\n\n${look.label}${panel?.host ? ' · open' : ''} · `
240
+ + `${meta.questions ?? '?'} question(s)${meta.model ? ` · ${meta.model}` : ''}\n\nLast active ${ago(meta.updated)}`)
241
+ item.accessibilityInformation = { label: `${meta.title}, ${look.label}${panel?.host ? ', open' : ''}, ${ago(meta.updated)}` }
242
+ item.command = { command: 'aipictury.sessions.open', title: 'Open', arguments: [node] }
243
+ return item
244
+ }
245
+
246
+ getParent() { return undefined }
247
+
248
+ // ---- actions ----
249
+
250
+ /** The sessions a command acts on: the clicked one, or every selected one. */
251
+ targets(node, selected) {
252
+ const list = selected?.length ? selected : node ? [node] : this.view?.selection || []
253
+ return list.filter((n) => n?.kind === 'session')
254
+ }
255
+
256
+ async open(node, { where = 'preferred' } = {}) {
257
+ const id = node.meta.id
258
+ const live = this.livePanel(id)
259
+ if (live?.host && where === 'preferred') {
260
+ if (live.kind === 'sidebar') await vscode.commands.executeCommand('aipictury.agent.focus')
261
+ else live.host.reveal?.()
262
+ return live
263
+ }
264
+ await this.freshen(id)
265
+ const panel = where === 'tab' || where === 'window' ? await workbench.openTab(this.registry) : await workbench.openPreferred(this.registry)
266
+ await panel.open(id, { focus: false })
267
+ if (where === 'window') await vscode.commands.executeCommand('workbench.action.moveEditorToNewWindow')
268
+ this.refresh()
269
+ return panel
270
+ }
271
+
272
+ /** Carried on somewhere else (the web app, another machine) since this copy was saved: the
273
+ * account's newer copy replaces it before it opens. Not while it runs here. */
274
+ async freshen(id) {
275
+ const cloud = this.cloud
276
+ const store = this.store()
277
+ if (!cloud || !store.folder || this.livePanel(id)?.abort) return
278
+ try {
279
+ const local = store.list().find((m) => m.id === id)
280
+ const remote = await cloud.summaryOf(store.folder, id)
281
+ if (!remote || !local || (remote.updated || 0) <= (local.updated || 0) || remote.device === cloud.device.id) return
282
+ const entry = await cloud.pullConversation(store.folder, id)
283
+ if (this.cloud !== cloud) return
284
+ store.save({ ...store.get(id), ...entry, server: this.server() })
285
+ log.info(`conversation ${id}: the newer copy from the account (${remote.origin || 'elsewhere'})`)
286
+ } catch (error) { log.warn(`conversation ${id}: the account's copy not checked: ${error.message}`) }
287
+ }
288
+
289
+ async moveToGroup(node, selected) {
290
+ const sessions = this.targets(node, selected)
291
+ if (!sessions.length) return
292
+ const store = this.store()
293
+ const groups = store.groups()
294
+ const pick = await vscode.window.showQuickPick([
295
+ ...groups.map((g) => ({ label: `$(folder) ${g.name}`, id: g.id })),
296
+ { label: '$(add) New group…', id: '' }], { title: `Move ${sessions.length > 1 ? `${sessions.length} conversations` : 'to a group'}` })
297
+ if (!pick) return
298
+ let id = pick.id
299
+ if (!id) {
300
+ const name = await vscode.window.showInputBox({ title: 'New group', prompt: 'Its name' })
301
+ if (!name?.trim()) return
302
+ id = store.createGroup(name).id
303
+ }
304
+ for (const s of sessions) store.patch(s.meta.id, { group: id, archived: undefined })
305
+ this.refresh()
306
+ }
307
+
308
+ /** Carries a web app chat on here: a copy becomes a new conversation of this project. */
309
+ async continueWeb(node) {
310
+ let chat
311
+ let messages
312
+ if (node.chat.fromAccount && this.cloud) {
313
+ // From the account: the chat as aipictury.com keeps it.
314
+ const got = await this.cloud.pullFrom('web', node.chat.id)
315
+ chat = { id: node.chat.id, title: got.meta.title, model: got.meta.model }
316
+ messages = (got.transcript || []).map((t) => ({ role: t.kind, content: t.text, created_at: (t.at || Date.now()) / 1000 }))
317
+ } else {
318
+ const response = await this.fetch(`${this.server()}/api/assistant/chats/${encodeURIComponent(node.chat.id)}`)
319
+ if (!response.ok) throw new Error(`the server answered ${response.status}`)
320
+ ;({ chat, messages = [] } = await response.json())
321
+ }
322
+ const kept = messages.filter((m) => (m.role === 'user' || m.role === 'assistant') && String(m.content || '').trim())
323
+ if (!kept.length) { vscode.window.showInformationMessage('That chat has nothing to carry on yet.'); return null }
324
+ const id = `c${Date.now().toString(36)}`
325
+ const at = (m) => Math.round((m.created_at || Date.now() / 1000) * 1000)
326
+ this.store().save({
327
+ id, title: `${chat.title || 'Web chat'} (from the web)`, renamed: true,
328
+ created: at(kept[0]), updated: Date.now(), server: this.server(), model: chat.model || null,
329
+ history: kept.map((m) => ({ role: m.role, content: m.content })),
330
+ transcript: kept.map((m) => ({ kind: m.role, text: m.content, at: at(m) })),
331
+ todos: [], usage: { turns: 0, prompt: 0, output: 0, seconds: 0 }, from: { web: chat.id },
332
+ })
333
+ log.info(`web chat ${chat.id} carried on as ${id}`)
334
+ return this.open({ meta: { id } })
335
+ }
336
+ }
337
+
338
+ /** Registers the view and its commands. Returns the view object (tests drive it). */
339
+ function register(context, { registry, store, server, activePanel }) {
340
+ const view = new SessionsView(context, { registry, store, server })
341
+ const tree = vscode.window.createTreeView('aipictury.sessions', { treeDataProvider: view, canSelectMany: true, showCollapseAll: true })
342
+ view.view = tree
343
+ view.describe()
344
+ const add = (d) => context.subscriptions.push(d)
345
+ add(tree)
346
+ const safely = (fn) => async (...args) => {
347
+ try { return await fn(...args) } catch (error) {
348
+ log.error('sessions view', error)
349
+ vscode.window.showErrorMessage(`AIPictury: ${error.message}`)
350
+ return undefined
351
+ }
352
+ }
353
+ const cmd = (name, fn) => add(vscode.commands.registerCommand(`aipictury.sessions.${name}`, safely(fn)))
354
+ const one = (node) => node || view.targets(null)[0]
355
+
356
+ cmd('open', (node) => one(node) && view.open(one(node)))
357
+ cmd('openInTab', (node) => one(node) && view.open(one(node), { where: 'tab' }))
358
+ cmd('openInNewWindow', (node) => one(node) && view.open(one(node), { where: 'window' }))
359
+ cmd('rename', async (node) => {
360
+ node = one(node); if (!node) return
361
+ const title = await vscode.window.showInputBox({ title: 'Rename the conversation', value: node.meta.title })
362
+ if (!title?.trim()) return
363
+ store().patch(node.meta.id, { title: title.trim().slice(0, 80), renamed: true })
364
+ const live = view.livePanel(node.meta.id)
365
+ if (live) { live.title = title.trim().slice(0, 80); live.postSession() }
366
+ view.refresh()
367
+ })
368
+ cmd('delete', async (node, selected) => {
369
+ const sessions = view.targets(node, selected)
370
+ if (!sessions.length) return
371
+ const yes = 'Delete'
372
+ const what = sessions.length > 1 ? `${sessions.length} conversations` : `“${sessions[0].meta.title}”`
373
+ if (await vscode.window.showWarningMessage(`Delete ${what}?`, { modal: true, detail: 'This cannot be undone. Your files are not touched.' }, yes) !== yes) return
374
+ for (const s of sessions) { if (!view.livePanel(s.meta.id)?.abort) store().remove(s.meta.id) }
375
+ view.refresh()
376
+ })
377
+ cmd('archive', (node, selected) => { for (const s of view.targets(node, selected)) store().patch(s.meta.id, { archived: true, group: undefined }); view.refresh() })
378
+ cmd('unarchive', (node, selected) => { for (const s of view.targets(node, selected)) store().patch(s.meta.id, { archived: undefined }); view.refresh() })
379
+ cmd('unarchiveAll', () => { for (const m of store().list()) if (m.archived) store().patch(m.id, { archived: undefined }); view.refresh() })
380
+ cmd('markUnread', (node, selected) => { for (const s of view.targets(node, selected)) store().patch(s.meta.id, { unread: true }); view.refresh() })
381
+ cmd('markRead', (node, selected) => { for (const s of view.targets(node, selected)) store().patch(s.meta.id, { unread: undefined }); view.refresh() })
382
+ cmd('moveToGroup', (node, selected) => view.moveToGroup(node, selected))
383
+ cmd('removeFromGroup', (node, selected) => { for (const s of view.targets(node, selected)) store().patch(s.meta.id, { group: undefined }); view.refresh() })
384
+ cmd('newGroup', async () => {
385
+ const name = await vscode.window.showInputBox({ title: 'New group', prompt: 'Its name' })
386
+ if (name?.trim()) { store().createGroup(name); view.refresh() }
387
+ })
388
+ cmd('renameGroup', async (node) => {
389
+ if (node?.kind !== 'group') return
390
+ const name = await vscode.window.showInputBox({ title: 'Rename the group', value: node.group.name })
391
+ if (name?.trim()) { store().renameGroup(node.group.id, name); view.refresh() }
392
+ })
393
+ cmd('deleteGroup', async (node) => {
394
+ if (node?.kind !== 'group') return
395
+ const yes = 'Delete group'
396
+ if (await vscode.window.showWarningMessage(`Delete the group “${node.group.name}”?`, { modal: true,
397
+ detail: 'Its conversations are kept, outside any group.' }, yes) !== yes) return
398
+ store().deleteGroup(node.group.id)
399
+ view.refresh()
400
+ })
401
+ cmd('addToGroup', async () => {
402
+ const panel = activePanel()
403
+ if (!panel?.id) { vscode.window.showInformationMessage('Ask something first: an empty conversation is not saved yet.'); return }
404
+ await panel.remember()
405
+ const meta = store().list().find((m) => m.id === panel.id)
406
+ if (meta) await view.moveToGroup({ kind: 'session', meta })
407
+ })
408
+ cmd('search', async () => {
409
+ const text = await vscode.window.showInputBox({ title: 'Search conversations', value: view.filter.search,
410
+ prompt: 'Matches titles and models, archived ones included. Empty clears the search.' })
411
+ if (text !== undefined) await view.setFilter({ search: text.trim() })
412
+ })
413
+ cmd('filter', async () => {
414
+ const f = view.filter
415
+ const items = [
416
+ { label: 'Open only', description: 'conversations open in a panel or tab', id: 'active', picked: f.active },
417
+ ...STATUSES.map((s) => ({ label: `$(${s.icon.replace('~spin', '')}) ${s.label}`, id: s.id, picked: f.statuses.includes(s.id) })),
418
+ ]
419
+ const picked = await vscode.window.showQuickPick(items, { canPickMany: true, title: 'Show only…',
420
+ placeHolder: 'Nothing picked shows everything' })
421
+ if (!picked) return
422
+ await view.setFilter({ active: picked.some((p) => p.id === 'active'), statuses: picked.map((p) => p.id).filter((id) => id !== 'active') })
423
+ })
424
+ cmd('clearFilter', () => view.setFilter({ active: false, statuses: [], search: '' }))
425
+ cmd('refresh', () => { view.webChats = null; view.refresh() })
426
+ cmd('continueWeb', (node) => node?.kind === 'webChat' && view.continueWeb(node))
427
+ cmd('openCloud', async (node) => {
428
+ if (node?.kind !== 'cloudChat') return
429
+ try { await view.openCloud(node) } catch (error) { vscode.window.showErrorMessage(`AIPictury: not opened — ${error.message}`) }
430
+ })
431
+
432
+ // Kept current: the store changes in any window; the panels change as they run.
433
+ let listening = null
434
+ const watch = () => { listening?.dispose(); listening = store().onChange(() => view.refresh()) }
435
+ watch()
436
+ add({ dispose: () => listening?.dispose() })
437
+ add(vscode.workspace.onDidChangeWorkspaceFolders(() => { watch(); view.refresh() }))
438
+
439
+ // archiveInactiveSessions: what has been idle that many days goes to Archived — never an
440
+ // open, unread or grouped conversation.
441
+ const days = Number(vscode.workspace.getConfiguration('aipictury').get('archiveInactiveSessions', 0)) || 0
442
+ if (days) {
443
+ const moved = store().archiveInactive(days, { open: new Set(registry.all().map((p) => p.id).filter(Boolean)) })
444
+ if (moved) log.info(`archived ${moved} conversation(s) idle for more than ${days} day(s)`)
445
+ }
446
+ return view
447
+ }
448
+
449
+ module.exports = { register, SessionsView, statusOf, STATUSES }