@olares/lares-mobile 0.15.4 → 0.15.6

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 (44) hide show
  1. package/README.md +6 -2
  2. package/package.json +2 -2
  3. package/src/App.vue +924 -184
  4. package/src/chat/ChatBar.vue +2 -2
  5. package/src/chat/Composer.vue +467 -16
  6. package/src/chat/Deliverables.vue +22 -10
  7. package/src/chat/HistoryPanel.vue +747 -55
  8. package/src/chat/MessageActions.vue +88 -34
  9. package/src/chat/QuestionCard.vue +5 -5
  10. package/src/chat/StageRow.vue +8 -5
  11. package/src/chat/Transcript.vue +20 -6
  12. package/src/chat/composer-tokens.js +32 -0
  13. package/src/chat/format.js +37 -0
  14. package/src/desktop/DesktopComposer.vue +871 -0
  15. package/src/desktop/DesktopContextMeter.vue +81 -0
  16. package/src/desktop/DesktopGoal.vue +88 -0
  17. package/src/desktop/DesktopPreview.vue +58 -0
  18. package/src/desktop/DesktopPreviewTabs.vue +82 -0
  19. package/src/desktop/DesktopQuestions.vue +118 -0
  20. package/src/desktop/DesktopQueue.vue +203 -0
  21. package/src/desktop/DesktopShell.vue +203 -0
  22. package/src/desktop/DesktopSidebar.vue +645 -0
  23. package/src/desktop/DesktopTodos.vue +52 -0
  24. package/src/desktop/DirectoryBrowser.vue +170 -0
  25. package/src/desktop/DirectoryNewFolder.vue +124 -0
  26. package/src/desktop/ui/PcDialog.vue +85 -0
  27. package/src/desktop/ui/PcPopover.vue +110 -0
  28. package/src/desktop/ui/PcTooltip.vue +95 -0
  29. package/src/desktop/ui/place.js +24 -0
  30. package/src/desktop/workspace-view.js +121 -0
  31. package/src/host.js +20 -2
  32. package/src/index.d.ts +2 -0
  33. package/src/mobile/MobileDirectoryNewFolder.vue +121 -0
  34. package/src/mobile/MobileDirectorySheet.vue +162 -0
  35. package/src/mobile/MobileNewSession.vue +217 -0
  36. package/src/mobile/MobileShell.vue +367 -0
  37. package/src/motion.css +166 -0
  38. package/src/preview/Preview.vue +91 -150
  39. package/src/preview/PreviewBody.vue +185 -0
  40. package/src/settings/AgentSettings.vue +126 -36
  41. package/src/settings/Select.vue +183 -0
  42. package/src/settings/SettingRow.vue +5 -4
  43. package/src/settings/Sheet.vue +4 -4
  44. package/src/styles.css +9 -0
package/src/App.vue CHANGED
@@ -1,138 +1,156 @@
1
1
  <template>
2
2
  <div class="lares-shell" :data-device="chatDevice">
3
- <LaresChatBar
3
+ <LaresDesktopShell
4
+ v-if="chatDevice === 'desktop'"
5
+ ref="shell"
6
+ :state="shellState"
4
7
  :t="t"
5
- :title="sessionTitle"
6
- :starting="starting"
7
- @history="toggleHistory"
8
+ :media-url="mediaUrl"
9
+ :scroll-top="scrollTop"
10
+ :remember-scroll="rememberScroll"
11
+ :sticking="sticking"
12
+ :preview-href="previewHref"
13
+ :model-key="modelKey"
14
+ :effort-name="effortName"
8
15
  @create="newChat"
16
+ @pick-session="pickSession"
17
+ @retry="retry"
18
+ @open-file="openFile"
19
+ @answer="answerQuestion"
20
+ @update-draft="updateDraft"
21
+ @send="send"
22
+ @stop="stop"
23
+ @steer-queue="steerQueue"
24
+ @choose-model="chooseModel"
25
+ @choose-effort="chooseEffort"
26
+ @choose-permission="choosePermission"
27
+ @disable-plan="disablePlan"
28
+ @goal-action="goalAction"
29
+ @answer-approval="answerApproval"
30
+ @answer-questions="answerQuestions"
31
+ @open-commands="loadCommands"
32
+ @open-references="loadReferences"
33
+ @pick-command="pickCommand"
34
+ @queue-action="updateQueue"
35
+ @files="attachFiles"
36
+ @remove-image="removeDraftImage"
37
+ @show-chat="showChat"
38
+ @close-preview-tab="closePreviewTab"
39
+ @refresh-workspaces="refreshWorkspaces"
40
+ @browse-workspaces="openWorkspaceBrowser"
41
+ @browse-directory="browseDirectory"
42
+ @close-directory="closeWorkspaceBrowser"
43
+ @choose-directory="chooseWorkspaceDirectory"
44
+ @create-folder="createDirectoryFolder"
45
+ @toggle-hidden="toggleDirectoryHidden"
46
+ @rename-workspace="renameWorkspace"
47
+ @delete-workspace="deleteWorkspace"
48
+ @move-workspace="moveWorkspace"
49
+ @move-session="moveSession"
50
+ @rename-session="renameSession"
51
+ @fork-session="forkSession"
52
+ @archive-session="archiveSession"
9
53
  />
10
- <LaresHistoryPanel
11
- :open="panel === 'history'"
12
- :sessions="sessions"
13
- :session-id="sessionId"
14
- :ready="sessionsReady"
15
- :t="t"
16
- @close="panel = ''"
17
- @pick="pickSession"
18
- />
19
- <div class="lares-shell__conversation">
20
- <p v-if="failed" class="lares-shell__status">{{ failText }}</p>
21
- <button v-if="failed" type="button" class="lares-shell__retry" :disabled="starting" @click="retry">
22
- {{ t("probe.retry") }}
23
- </button>
24
- <LaresTranscript
25
- ref="transcript"
26
- :items="viewItems"
27
- :running="running"
28
- :loading="historyLoading || starting"
29
- :previews="previews"
30
- :media-url="mediaUrl"
31
- :scroll-top="scrollTop"
32
- :remember-scroll="rememberScroll"
33
- :sticking="sticking"
34
- :session-id="sessionId"
35
- :question="question"
36
- :question-busy="questionBusy"
37
- :t="t"
38
- @open="openFile"
39
- @media="pinLog"
40
- @answer="answerQuestion"
41
- />
42
- <LaresComposer
43
- :draft="draft"
44
- :sending="sending"
45
- :can-send="canSend"
46
- :model-label="modelLabel"
47
- :model-busy="modelBusy"
48
- :effort-label="effortLabel"
49
- :effort-disabled="effortDisabled"
50
- :attach-pending="upload.pending > 0"
51
- :attach-disabled="!sessionId || historyLoading || starting"
52
- :voice-phase="voicePhase"
53
- :voice-elapsed="voiceElapsed"
54
- :voice-error="voiceErrorText"
55
- :failures="upload.failures"
56
- :t="t"
57
- @update:draft="draft = $event"
58
- @send="send"
59
- @model="openModelSheet"
60
- @effort="openEffortSheet"
61
- @files="attachFiles"
62
- @voice="toggleVoice"
63
- @hold-start="onHoldStart"
64
- @hold-end="onHoldEnd"
65
- />
66
- </div>
67
- <LaresSheet :open="modelSheet" :title="t('model.menuAria')" @close="modelSheet = false">
68
- <p v-if="modelError" class="lares-shell__status">{{ modelError }}</p>
69
- <p v-else-if="!models" class="lares-shell__hint">{{ t("agent.loading") }}</p>
70
- <p v-else-if="!modelGroups.length" class="lares-shell__hint">{{ t("model.empty") }}</p>
71
- <template v-for="section in modelGroups" :key="section.provider">
72
- <p class="lares-shell__heading">{{ section.provider }}</p>
73
- <LaresSettingRow
74
- v-for="item in section.models"
75
- :key="modelKey(item)"
76
- :label="item.name"
77
- :checked="modelKey(item) === modelCurrent"
78
- :disabled="modelBusy"
79
- @click="chooseModel(item)"
80
- />
81
- </template>
82
- </LaresSheet>
83
- <LaresSheet :open="effortSheet" :title="t('reasoning.title')" @close="effortSheet = false">
84
- <p v-if="!effortRows.length" class="lares-shell__hint">{{ t("reasoning.default") }}</p>
85
- <LaresSettingRow
86
- v-for="row in effortRows"
87
- :key="row.key"
88
- :label="effortName(row)"
89
- :checked="row.id === effortId"
90
- :disabled="modelBusy"
91
- @click="chooseEffort(row.id)"
92
- />
93
- </LaresSheet>
94
- <LaresPreview
95
- v-if="preview.path"
96
- :path="preview.path"
97
- :session-id="sessionId"
98
- :status="preview.status"
99
- :data="preview.data"
100
- :error="preview.error"
101
- :media-src="previewMediaSrc"
102
- :download-href="previewDownloadHref"
103
- :href-for="previewHref"
54
+ <LaresMobileShell
55
+ v-else
56
+ ref="shell"
57
+ :state="shellState"
104
58
  :t="t"
105
- @close="closePreview"
106
- @retry="openFile(preview.path)"
107
- @open="openFile"
59
+ :media-url="mediaUrl"
60
+ :scroll-top="scrollTop"
61
+ :remember-scroll="rememberScroll"
62
+ :sticking="sticking"
63
+ :preview-href="previewHref"
64
+ :model-key="modelKey"
65
+ :effort-name="effortName"
66
+ @history="toggleHistory"
67
+ @create="newChat"
68
+ @close-history="panel = ''"
69
+ @pick-session="pickSession"
70
+ @refresh-workspaces="refreshWorkspaces"
71
+ @browse-workspaces="openWorkspaceBrowser"
72
+ @browse-directory="browseDirectory"
73
+ @close-directory="closeWorkspaceBrowser"
74
+ @choose-directory="chooseWorkspaceDirectory"
75
+ @create-folder="createDirectoryFolder"
76
+ @toggle-hidden="toggleDirectoryHidden"
77
+ @move-session="moveSession"
78
+ @rename-session="renameSession"
79
+ @archive-session="archiveSession"
80
+ @pick-workspace="pickWorkspace"
81
+ @retry="retry"
82
+ @open-file="openFile"
83
+ @answer="answerQuestion"
84
+ @update-draft="updateDraft"
85
+ @send="send"
86
+ @open-model="openModelSheet"
87
+ @open-effort="openEffortSheet"
88
+ @files="attachFiles"
89
+ @voice="toggleVoice"
90
+ @hold-start="onHoldStart"
91
+ @hold-end="onHoldEnd"
92
+ @close-model="modelSheet = false"
93
+ @choose-model="chooseModel"
94
+ @close-effort="effortSheet = false"
95
+ @choose-effort="chooseEffort"
96
+ @close-preview="closePreview"
97
+ @remove-image="removeDraftImage"
98
+ @open-commands="loadCommands"
99
+ @open-references="loadReferences"
100
+ @pick-command="pickCommand"
108
101
  />
109
102
  </div>
110
103
  </template>
111
104
 
112
105
  <script>
106
+ import { pickHostPorts } from "@olares/lares-core/larepass/host";
113
107
  import { connectChat } from "./runtime.js";
114
108
  import { createT } from "./i18n.js";
115
109
  import { failText, withPendingUser } from "./chat/format.js";
116
110
  import { createVoiceCapture } from "./chat/voice.js";
117
111
  import { appendDraftMentions } from "@olares/lares-core/files/mention";
118
- import { FileIntake, partitionDocumentsBySize } from "@olares/lares-core/files/intake";
112
+ import { FileIntake, partitionDocumentsBySize, splitComposerFiles } from "@olares/lares-core/files/intake";
113
+ import {
114
+ createDraftImageEntry,
115
+ draftHasSendableContent,
116
+ draftImageStatus,
117
+ draftImagesUploading,
118
+ } from "@olares/lares-core/files/draft-images";
119
119
  import { DEFAULT_MAX_UPLOAD_BYTES } from "@olares/lares-core/files/limits";
120
120
  import { groupModelsByProvider, effortMenuRows, reasoningOfModel, currentEffortId, selectionKey } from "@olares/lares-core/router/session-model";
121
121
  import { messageFromCode } from "@olares/lares-core/i18n/t";
122
122
  import { parseAskUserQuestions, singleSelectAnswer } from "@olares/lares-core/larepass/questions";
123
+ import { applyCommand } from "@olares/lares-core/larepass/commands";
124
+ import { permissionOption, presetDescription, presetLabel } from "@olares/lares-core/larepass/permissions";
125
+ import { DEFAULT_BUSY_ENTER } from "@olares/lares-core/larepass/submission-settings";
126
+ import { subscribeConversationSettings } from "@olares/lares-core/larepass/settings";
127
+ import { markSeen, pruneSeen, readSeen, SEEN_STORAGE_KEY, unseenSessions } from "@olares/lares-core/larepass/unread";
128
+ import { fileName } from "@olares/lares-core/files/filename";
129
+ import { closeTab, openTab, touchTab } from "@olares/lares-core/files/preview-tabs";
123
130
  import { chatDevice as resolveChatDevice } from "./layout.js";
124
- import LaresChatBar from "./chat/ChatBar.vue";
125
- import LaresHistoryPanel from "./chat/HistoryPanel.vue";
126
- import LaresTranscript from "./chat/Transcript.vue";
127
- import LaresComposer from "./chat/Composer.vue";
128
- import LaresPreview from "./preview/Preview.vue";
129
- import LaresSheet from "./settings/Sheet.vue";
130
- import LaresSettingRow from "./settings/SettingRow.vue";
131
+ import LaresMobileShell from "./mobile/MobileShell.vue";
132
+ import LaresDesktopShell from "./desktop/DesktopShell.vue";
131
133
  import "./styles.css";
132
134
 
135
+ function imageData(file) {
136
+ return file.arrayBuffer().then((buffer) => {
137
+ const bytes = new Uint8Array(buffer);
138
+ let binary = "";
139
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
140
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
141
+ }
142
+ return {
143
+ type: "image",
144
+ mediaType: file.type,
145
+ data: btoa(binary),
146
+ ...(file.name ? { name: file.name } : {}),
147
+ };
148
+ });
149
+ }
150
+
133
151
  export default {
134
152
  name: "LaresApp",
135
- components: { LaresChatBar, LaresHistoryPanel, LaresTranscript, LaresComposer, LaresPreview, LaresSheet, LaresSettingRow },
153
+ components: { LaresMobileShell, LaresDesktopShell },
136
154
  props: {
137
155
  locale: { type: String, default: "en" },
138
156
  device: { type: String, default: "mobile" },
@@ -140,22 +158,33 @@ export default {
140
158
  proxyPrefix: { type: String, default: undefined },
141
159
  request: { type: Function, default: undefined },
142
160
  env: { type: Object, default: undefined },
161
+ // Vue drops an undeclared prop into attrs, so a port missing from this list
162
+ // never reaches `ports` below no matter what the host passes.
163
+ socketProtocol: { type: [Function, String, Array], default: undefined },
143
164
  },
144
165
  data() {
145
166
  return {
146
167
  sending: false,
168
+ stopping: false,
147
169
  starting: false,
148
170
  hostGen: 0,
171
+ modelGen: 0,
149
172
  running: false,
150
173
  sessionId: "",
151
174
  draft: "",
175
+ draftBySession: {},
176
+ imagesBySession: {},
152
177
  failed: "",
153
178
  error: "",
154
179
  items: [],
155
180
  pendingUser: "",
156
181
  unsub: null,
157
182
  previews: {},
158
- preview: { path: "", status: "idle", data: null, error: "" },
183
+ previewTabs: [],
184
+ previewLru: [],
185
+ previewPath: "",
186
+ previewMode: "chat",
187
+ previewContents: {},
159
188
  panel: "",
160
189
  sessions: [],
161
190
  sessionsReady: false,
@@ -167,6 +196,7 @@ export default {
167
196
  effortSheet: false,
168
197
  effortByModel: {},
169
198
  upload: { pending: 0, failures: [] },
199
+ draftImages: [],
170
200
  voicePhase: "idle",
171
201
  voiceElapsed: 0,
172
202
  voiceError: "",
@@ -174,16 +204,49 @@ export default {
174
204
  intakeUnsub: null,
175
205
  question: null,
176
206
  questionBusy: false,
207
+ approval: null,
208
+ approvalBusy: false,
209
+ workspaces: [],
210
+ workspacesReady: false,
211
+ workspaceError: "",
212
+ archivedSessionIds: [],
213
+ seen: { since: 0, sessions: {} },
214
+ permissions: null,
215
+ plan: null,
216
+ contextPressure: null,
217
+ contextBreakdown: null,
218
+ goal: null,
219
+ goalBusy: false,
220
+ todos: null,
221
+ permissionBusy: false,
222
+ commands: null,
223
+ commandsSessionId: "",
224
+ commandsError: "",
225
+ references: [],
226
+ referencesLoading: false,
227
+ referencesError: "",
228
+ referenceGen: 0,
229
+ queue: [],
230
+ queueBusy: "",
231
+ busyEnter: DEFAULT_BUSY_ENTER,
232
+ conversationUnsub: null,
233
+ directory: {
234
+ open: false,
235
+ loading: false,
236
+ listing: null,
237
+ error: "",
238
+ creating: false,
239
+ creatingFolder: false,
240
+ showHidden: false,
241
+ selectPath: "",
242
+ },
177
243
  };
178
244
  },
179
245
  computed: {
180
246
  ports() {
181
- return {
182
- baseUrl: this.baseUrl,
183
- proxyPrefix: this.proxyPrefix,
184
- request: this.request,
185
- env: this.env,
186
- };
247
+ // Built from the canonical key list rather than spelled out, so adding a
248
+ // port does not need this object edited in lockstep.
249
+ return pickHostPorts(this);
187
250
  },
188
251
  runtime() {
189
252
  return connectChat(this.ports);
@@ -197,8 +260,116 @@ export default {
197
260
  chatDevice() {
198
261
  return resolveChatDevice(this.device);
199
262
  },
263
+ currentWorkspaceId() {
264
+ return this.workspaces.find((workspace) => (
265
+ workspace.sessionIds?.includes(this.sessionId)
266
+ ))?.workspaceId || "";
267
+ },
268
+ shellState() {
269
+ return {
270
+ sending: this.sending,
271
+ stopping: this.stopping,
272
+ starting: this.starting,
273
+ running: this.running,
274
+ sessionId: this.sessionId,
275
+ sessionTitle: this.sessionTitle,
276
+ draft: this.draft,
277
+ failed: this.failed,
278
+ failText: this.failText,
279
+ viewItems: this.viewItems,
280
+ historyLoading: this.historyLoading,
281
+ previews: this.previews,
282
+ preview: this.preview,
283
+ previewTabs: this.previewTabs,
284
+ previewMode: this.previewMode,
285
+ previewMediaSrc: this.previewMediaSrc,
286
+ previewDownloadHref: this.previewDownloadHref,
287
+ panel: this.panel,
288
+ sessions: this.sessions,
289
+ sessionsReady: this.sessionsReady,
290
+ models: this.models,
291
+ modelError: this.modelError,
292
+ modelGroups: this.modelGroups,
293
+ modelCurrent: this.modelCurrent,
294
+ modelLabel: this.modelLabel,
295
+ modelBusy: this.modelBusy,
296
+ modelBlocked: this.modelBlocked,
297
+ modelSheet: this.modelSheet,
298
+ effortSheet: this.effortSheet,
299
+ effortRows: this.effortRows,
300
+ effortId: this.effortId,
301
+ effortLabel: this.effortLabel,
302
+ effortDisabled: this.effortDisabled,
303
+ upload: this.upload,
304
+ draftImages: this.draftImages,
305
+ voicePhase: this.voicePhase,
306
+ voiceElapsed: this.voiceElapsed,
307
+ voiceErrorText: this.voiceErrorText,
308
+ question: this.question,
309
+ questionBusy: this.questionBusy,
310
+ approval: this.approval,
311
+ approvalBusy: this.approvalBusy,
312
+ canSend: this.canSend,
313
+ workspaces: this.workspaces,
314
+ workspacesReady: this.workspacesReady,
315
+ workspaceError: this.workspaceError,
316
+ archivedSessionIds: this.archivedSessionIds,
317
+ currentWorkspaceId: this.currentWorkspaceId,
318
+ unseen: this.unseen,
319
+ permissionRows: this.permissionRows,
320
+ permissionCurrent: this.permissionCurrent,
321
+ permissionLabel: this.permissionLabel,
322
+ permissionHint: this.permissionHint,
323
+ permissionBusy: this.permissionBusy,
324
+ plan: this.plan,
325
+ planActive: this.planActive,
326
+ contextPressure: this.contextPressure,
327
+ contextBreakdown: this.contextBreakdown,
328
+ goal: this.goal,
329
+ goalBusy: this.goalBusy,
330
+ todos: this.todos,
331
+ commands: this.commands,
332
+ commandsError: this.commandsError,
333
+ references: this.references,
334
+ referencesLoading: this.referencesLoading,
335
+ referencesError: this.referencesError,
336
+ queue: this.queue,
337
+ queueBusy: this.queueBusy,
338
+ busyEnter: this.busyEnter,
339
+ directory: this.directory,
340
+ };
341
+ },
342
+ unseen() {
343
+ return unseenSessions(this.sessions, this.seen, this.sessionId);
344
+ },
345
+ permissionRows() {
346
+ return (this.permissions?.options ?? [])
347
+ .filter((option) => option.value !== "custom")
348
+ .map((option) => ({
349
+ value: option.value,
350
+ label: presetLabel(this.t, option),
351
+ hint: presetDescription(this.t, option),
352
+ }));
353
+ },
354
+ planActive() {
355
+ return Boolean(this.plan && (this.plan.pending ? !this.plan.active : this.plan.active));
356
+ },
357
+ permissionCurrent() {
358
+ return this.permissions?.currentValue || "";
359
+ },
360
+ permissionLabel() {
361
+ return presetLabel(this.t, permissionOption(this.permissions));
362
+ },
363
+ permissionHint() {
364
+ return presetDescription(this.t, permissionOption(this.permissions));
365
+ },
200
366
  canSend() {
201
- return Boolean(this.draft.trim()) && Boolean(this.sessionId) && !this.running && !this.historyLoading && this.upload.pending === 0;
367
+ return draftHasSendableContent(this.draft, this.draftImages)
368
+ && Boolean(this.sessionId)
369
+ && !this.modelBlocked
370
+ && !this.historyLoading
371
+ && this.upload.pending === 0
372
+ && !draftImagesUploading(this.draftImages);
202
373
  },
203
374
  sessionTitle() {
204
375
  const row = this.sessions.find((item) => item.sessionId === this.sessionId);
@@ -219,6 +390,9 @@ export default {
219
390
  return reasoningOfModel(this.currentListedModel);
220
391
  },
221
392
  effortId() {
393
+ if (this.chatDevice === "desktop") {
394
+ return currentEffortId(this.models?.default, this.reasoning);
395
+ }
222
396
  if (!(this.modelCurrent in this.effortByModel)) {
223
397
  return currentEffortId(this.models?.default, this.reasoning);
224
398
  }
@@ -240,6 +414,11 @@ export default {
240
414
  failText() {
241
415
  return failText(this.t, this.failed, this.error);
242
416
  },
417
+ preview() {
418
+ if (!this.previewPath) return { path: "", status: "idle", data: null, error: "" };
419
+ const content = this.previewContents[this.previewPath];
420
+ return { path: this.previewPath, ...(content ?? { status: "loading", data: null, error: "" }) };
421
+ },
243
422
  previewMediaSrc() {
244
423
  const data = this.preview.data;
245
424
  if (!data) return "";
@@ -271,6 +450,9 @@ export default {
271
450
  modelBusy() {
272
451
  return Boolean(this.modelPending) || (this.modelSheet && !this.models);
273
452
  },
453
+ modelBlocked() {
454
+ return this.chatDevice === "desktop" && this.models?.routable === false;
455
+ },
274
456
  voiceErrorText() {
275
457
  return this.voiceError ? messageFromCode(this.t, this.voiceError, "error.voice_failed") : "";
276
458
  },
@@ -290,7 +472,10 @@ export default {
290
472
  },
291
473
  },
292
474
  created() {
475
+ // Per-path request tokens: reactive state would re-render on every read.
476
+ this.previewGen = new Map();
293
477
  this.intake = new FileIntake((file, sessionId, options) => this.runtime.upload(file, options, sessionId));
478
+ this.imageUploadControllers = new Map();
294
479
  this.voiceCap = createVoiceCapture({
295
480
  transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
296
481
  getDraft: () => this.draft,
@@ -311,8 +496,19 @@ export default {
311
496
  } catch {
312
497
  this.effortByModel = {};
313
498
  }
499
+ try {
500
+ this.seen = readSeen(JSON.parse(localStorage.getItem(SEEN_STORAGE_KEY) || "null"));
501
+ } catch {
502
+ this.seen = readSeen(null);
503
+ }
504
+ // `since` is the floor for sessions this device has never opened, so pin it
505
+ // on the first launch instead of letting every launch move it forward.
506
+ this.persistSeen();
314
507
  },
315
508
  mounted() {
509
+ this.conversationUnsub = subscribeConversationSettings((section) => {
510
+ this.busyEnter = section.busyEnter;
511
+ });
316
512
  this.retry();
317
513
  },
318
514
  activated() {
@@ -323,9 +519,14 @@ export default {
323
519
  },
324
520
  beforeUnmount() {
325
521
  this.captureLog();
522
+ this.noteSeen(this.sessionId);
523
+ for (const controller of this.imageUploadControllers?.values?.() ?? []) controller.abort();
524
+ this.imageUploadControllers?.clear?.();
326
525
  this.voiceCap?.dispose();
526
+ this.conversationUnsub?.();
327
527
  this.intakeUnsub?.();
328
528
  if (this.sessionId) this.intake?.cancelSession(this.sessionId);
529
+ this.releaseDraftImages();
329
530
  this.unsub?.();
330
531
  this.unsub = null;
331
532
  },
@@ -343,18 +544,36 @@ export default {
343
544
  return this.runtime.sticking();
344
545
  },
345
546
  restoreLog() {
346
- this.$refs.transcript?.restoreLog?.();
547
+ this.$refs.shell?.restoreLog?.();
347
548
  },
348
549
  captureLog() {
349
- this.$refs.transcript?.captureLog?.();
550
+ this.$refs.shell?.captureLog?.();
350
551
  },
351
552
  pinLog() {
352
- this.$refs.transcript?.scheduleLogPin?.();
553
+ this.$refs.shell?.pinLog?.();
353
554
  },
354
555
  applySnap(snap) {
355
- if (snap.sessionId !== this.sessionId) {
556
+ const sessionChanged = snap.sessionId !== this.sessionId;
557
+ if (sessionChanged) {
558
+ const previousKey = this.sessionId || "@pending";
559
+ const nextKey = snap.sessionId || "@pending";
560
+ this.draftBySession = { ...this.draftBySession, [previousKey]: this.draft };
561
+ this.imagesBySession = { ...this.imagesBySession, [previousKey]: this.draftImages };
562
+ if (previousKey === "@pending" && snap.sessionId && !(nextKey in this.draftBySession)) {
563
+ this.draftBySession = { ...this.draftBySession, [nextKey]: this.draft };
564
+ this.imagesBySession = { ...this.imagesBySession, [nextKey]: this.draftImages };
565
+ }
566
+ this.draft = this.draftBySession[nextKey] ?? "";
567
+ this.draftImages = this.imagesBySession[nextKey] ?? [];
568
+ // Leaving a session marks everything in it as watched, and arriving
569
+ // clears the dot on the one now on screen.
570
+ this.noteSeen(this.sessionId);
571
+ this.noteSeen(snap.sessionId);
356
572
  this.pendingUser = "";
357
573
  this.previews = {};
574
+ this.resetPreview();
575
+ this.commands = null;
576
+ this.commandsError = "";
358
577
  }
359
578
  this.sessionId = snap.sessionId;
360
579
  this.items = snap.items;
@@ -363,14 +582,111 @@ export default {
363
582
  this.error = snap.error;
364
583
  this.sessions = snap.sessions ?? this.sessions;
365
584
  this.sessionsReady = Boolean(snap.sessionsReady);
585
+ this.workspaces = snap.workspaces ?? this.workspaces;
586
+ this.workspacesReady = Boolean(snap.workspacesReady);
587
+ this.workspaceError = snap.workspaceError || "";
588
+ this.archivedSessionIds = snap.archivedSessionIds ?? this.archivedSessionIds;
366
589
  this.historyLoading = Boolean(snap.historyLoading);
590
+ this.permissions = snap.permissions ?? null;
591
+ this.plan = snap.plan ?? null;
592
+ this.contextPressure = snap.contextPressure ?? null;
593
+ this.contextBreakdown = snap.contextBreakdown ?? null;
594
+ this.goal = snap.goal ?? null;
595
+ this.todos = snap.todos ?? null;
596
+ this.queue = snap.queue ?? [];
367
597
  this.question = snap.question || null;
368
598
  if (!this.question) this.questionBusy = false;
599
+ this.approval = snap.approval || null;
600
+ if (!this.approval) this.approvalBusy = false;
601
+ if (sessionChanged && this.chatDevice === "desktop") {
602
+ void this.loadSessionModels(snap.sessionId);
603
+ }
369
604
  if (this.pendingUser && snap.items.some((row) => row.type === "user" && row.text === this.pendingUser)) {
370
605
  this.pendingUser = "";
371
606
  }
372
607
  this.pinLog();
373
608
  },
609
+ noteSeen(sessionId) {
610
+ if (!sessionId) return;
611
+ const next = markSeen(this.seen, sessionId);
612
+ if (next === this.seen) return;
613
+ this.seen = next;
614
+ this.persistSeen();
615
+ },
616
+ updateDraft(value) {
617
+ this.draft = String(value ?? "");
618
+ const key = this.sessionId || "@pending";
619
+ this.draftBySession = { ...this.draftBySession, [key]: this.draft };
620
+ },
621
+ persistSeen() {
622
+ if (this.sessionsReady && this.sessions.length) {
623
+ this.seen = pruneSeen(this.seen, this.sessions);
624
+ }
625
+ try {
626
+ localStorage.setItem(SEEN_STORAGE_KEY, JSON.stringify(this.seen));
627
+ } catch {
628
+ // storage may be blocked
629
+ }
630
+ },
631
+ async choosePermission(value) {
632
+ if (!value || value === this.permissionCurrent || this.permissionBusy) return;
633
+ this.permissionBusy = true;
634
+ try {
635
+ const switched = await this.runtime.setPermission(value);
636
+ if (!switched.ok) {
637
+ this.failed = switched.error?.message || switched.error?.code || "permission";
638
+ }
639
+ } finally {
640
+ this.permissionBusy = false;
641
+ }
642
+ },
643
+ async disablePlan() {
644
+ if (!this.planActive) return;
645
+ const result = await this.runtime.runCommand("/plan off");
646
+ if (!result.ok) this.failed = result.error?.message || result.error?.code || "plan";
647
+ },
648
+ async goalAction(kind, value) {
649
+ if (this.goalBusy) return;
650
+ this.goalBusy = true;
651
+ try {
652
+ const result = await this.runtime.goalAction(kind, value);
653
+ if (!result.ok) this.failed = result.error?.message || result.error?.code || "goal";
654
+ } finally {
655
+ this.goalBusy = false;
656
+ }
657
+ },
658
+ async loadCommands() {
659
+ if (this.commands && this.commandsSessionId === this.sessionId) return;
660
+ this.commandsError = "";
661
+ try {
662
+ this.commands = await this.runtime.listCommands();
663
+ this.commandsSessionId = this.sessionId;
664
+ } catch (err) {
665
+ this.commands = [];
666
+ this.commandsError = err instanceof Error ? err.message : String(err);
667
+ }
668
+ },
669
+ async loadReferences({ query = "", quoted = false } = {}) {
670
+ const gen = ++this.referenceGen;
671
+ this.referencesLoading = true;
672
+ this.referencesError = "";
673
+ try {
674
+ const rows = await this.runtime.listReferences(query, quoted);
675
+ if (gen !== this.referenceGen) return;
676
+ this.references = rows;
677
+ } catch (err) {
678
+ if (gen !== this.referenceGen) return;
679
+ this.references = [];
680
+ this.referencesError = err instanceof Error ? err.message : String(err);
681
+ } finally {
682
+ if (gen === this.referenceGen) this.referencesLoading = false;
683
+ }
684
+ },
685
+ pickCommand(command) {
686
+ if (!command?.name) return;
687
+ this.updateDraft(applyCommand(this.draft, command));
688
+ this.$refs.shell?.focusComposer?.();
689
+ },
374
690
  async hydrateFiles(paths) {
375
691
  const missing = [...new Set(paths)].filter((path) => !(path in this.previews));
376
692
  if (!missing.length) return;
@@ -399,30 +715,122 @@ export default {
399
715
  this.questionBusy = false;
400
716
  }
401
717
  },
402
- async openFile(path) {
403
- this.preview = { path, status: "loading", data: null, error: "" };
718
+ async answerQuestions(answer) {
719
+ if (!answer?.answers?.length || this.questionBusy) return;
720
+ this.questionBusy = true;
721
+ try {
722
+ const result = await this.runtime.answerQuestion(answer.answers);
723
+ if (!result?.ok) this.questionBusy = false;
724
+ } catch {
725
+ this.questionBusy = false;
726
+ }
727
+ },
728
+ async answerApproval(outcome) {
729
+ if (!this.approval || this.approvalBusy) return;
730
+ this.approvalBusy = true;
731
+ try {
732
+ const result = await this.runtime.answerApproval(outcome);
733
+ if (!result?.ok) this.approvalBusy = false;
734
+ } catch {
735
+ this.approvalBusy = false;
736
+ }
737
+ },
738
+ openFile(path) {
739
+ const { tabs, lru, evicted } = openTab({ tabs: this.previewTabs, lru: this.previewLru }, path);
740
+ this.previewTabs = tabs;
741
+ this.previewLru = lru;
742
+ if (evicted) this.dropPreviewContent(evicted.path);
743
+ this.previewPath = path;
744
+ this.previewMode = "preview";
745
+ return this.loadPreview(path);
746
+ },
747
+ showChat() {
748
+ this.previewMode = "chat";
749
+ },
750
+ closePreviewTab(path) {
751
+ const closed = closeTab(
752
+ { tabs: this.previewTabs, lru: this.previewLru, activePath: this.previewPath },
753
+ path,
754
+ );
755
+ if (!closed) return;
756
+ this.previewTabs = closed.tabs;
757
+ this.previewLru = closed.lru;
758
+ this.previewPath = closed.activePath ?? "";
759
+ if (!closed.activePath) this.previewMode = "chat";
760
+ this.dropPreviewContent(path);
761
+ },
762
+ closePreview() {
763
+ if (this.previewPath) this.closePreviewTab(this.previewPath);
764
+ },
765
+ resetPreview() {
766
+ this.previewTabs = [];
767
+ this.previewLru = [];
768
+ this.previewPath = "";
769
+ this.previewMode = "chat";
770
+ this.previewContents = {};
771
+ this.previewGen.clear();
772
+ },
773
+ dropPreviewContent(path) {
774
+ const rest = { ...this.previewContents };
775
+ delete rest[path];
776
+ this.previewContents = rest;
777
+ this.previewGen.delete(path);
778
+ },
779
+ /**
780
+ * The Host answers with its own spelling of the path, so a file opened from
781
+ * a produced-file chip and the same file reached through a markdown link
782
+ * end up on one tab instead of two.
783
+ */
784
+ adoptPreviewPath(requested, answered) {
785
+ const canonical = answered || requested;
786
+ if (canonical === requested) return requested;
787
+ this.previewTabs = this.previewTabs.some((tab) => tab.path === canonical)
788
+ ? this.previewTabs.filter((tab) => tab.path !== requested)
789
+ : this.previewTabs.map((tab) => (
790
+ tab.path === requested ? { path: canonical, name: fileName(canonical) } : tab
791
+ ));
792
+ this.previewLru = touchTab(this.previewLru.filter((item) => item !== requested), canonical);
793
+ if (this.previewPath === requested) this.previewPath = canonical;
794
+ this.dropPreviewContent(requested);
795
+ return canonical;
796
+ },
797
+ /**
798
+ * Reopening a tab re-reads the file: the agent may have rewritten it since.
799
+ * Cached content stays on screen until the fresh read lands, so switching
800
+ * tabs does not flash a spinner over a file that is already there.
801
+ */
802
+ async loadPreview(path) {
803
+ const gen = (this.previewGen.get(path) ?? 0) + 1;
804
+ this.previewGen.set(path, gen);
805
+ if (this.previewContents[path]?.status !== "ready") {
806
+ this.setPreviewContent(path, gen, { status: "loading", data: null, error: "" });
807
+ }
404
808
  try {
405
809
  const data = await this.runtime.preview(path);
810
+ if (this.previewGen.get(path) !== gen) return;
406
811
  this.previews = { ...this.previews, [path]: data };
407
- this.preview = { path, status: "ready", data, error: "" };
812
+ const canonical = this.adoptPreviewPath(path, data?.path);
813
+ this.previewGen.set(canonical, gen);
814
+ this.setPreviewContent(canonical, gen, { status: "ready", data, error: "" });
408
815
  } catch (err) {
409
- this.preview = {
410
- path,
816
+ this.setPreviewContent(path, gen, {
411
817
  status: "error",
412
818
  data: null,
413
819
  error: err instanceof Error ? err.message : "file_preview_failed",
414
- };
820
+ });
415
821
  }
416
822
  },
417
- closePreview() {
418
- this.preview = { path: "", status: "idle", data: null, error: "" };
823
+ setPreviewContent(path, gen, content) {
824
+ if (this.previewGen.get(path) !== gen) return;
825
+ this.previewContents = { ...this.previewContents, [path]: content };
419
826
  },
420
827
  async retry() {
421
828
  const gen = ++this.hostGen;
422
829
  this.starting = true;
423
830
  try {
424
- await this.runtime.start();
831
+ await this.runtime.start({ workspaces: this.chatDevice === "desktop" });
425
832
  if (gen !== this.hostGen) return;
833
+ if (this.chatDevice !== "desktop") await this.runtime.refreshWorkspaces();
426
834
  await this.loadComposerMeta();
427
835
  } finally {
428
836
  if (gen === this.hostGen) this.starting = false;
@@ -437,6 +845,9 @@ export default {
437
845
  },
438
846
  switchHost() {
439
847
  this.voiceCap?.stop?.(true);
848
+ for (const controller of this.imageUploadControllers.values()) controller.abort();
849
+ this.imageUploadControllers.clear();
850
+ this.releaseDraftImages();
440
851
  this.intakeUnsub?.();
441
852
  if (this.sessionId) this.intake?.cancelSession(this.sessionId);
442
853
  this.sessionId = "";
@@ -444,7 +855,7 @@ export default {
444
855
  this.sessions = [];
445
856
  this.sessionsReady = false;
446
857
  this.previews = {};
447
- this.preview = { path: "", status: "idle", data: null, error: "" };
858
+ this.resetPreview();
448
859
  this.pendingUser = "";
449
860
  this.failed = "";
450
861
  this.error = "";
@@ -455,6 +866,34 @@ export default {
455
866
  this.panel = "";
456
867
  this.question = null;
457
868
  this.questionBusy = false;
869
+ this.approval = null;
870
+ this.approvalBusy = false;
871
+ this.workspaces = [];
872
+ this.workspacesReady = false;
873
+ this.workspaceError = "";
874
+ this.archivedSessionIds = [];
875
+ this.permissions = null;
876
+ this.permissionBusy = false;
877
+ this.goal = null;
878
+ this.goalBusy = false;
879
+ this.todos = null;
880
+ this.commands = null;
881
+ this.commandsSessionId = "";
882
+ this.commandsError = "";
883
+ this.references = [];
884
+ this.referencesLoading = false;
885
+ this.referencesError = "";
886
+ this.referenceGen += 1;
887
+ this.queue = [];
888
+ this.queueBusy = "";
889
+ this.busyEnter = DEFAULT_BUSY_ENTER;
890
+ this.directory = {
891
+ open: false,
892
+ loading: false,
893
+ listing: null,
894
+ error: "",
895
+ creating: false,
896
+ };
458
897
  this.bindIntake("");
459
898
  this.bindRuntime();
460
899
  this.retry();
@@ -474,11 +913,22 @@ export default {
474
913
  async loadComposerMeta() {
475
914
  const settings = this.runtime.settings;
476
915
  if (!settings) return;
477
- try {
478
- this.models = await settings.models();
479
- this.modelError = "";
480
- } catch (err) {
481
- this.modelError = err instanceof Error ? err.message : String(err);
916
+ if (this.chatDevice === "desktop") {
917
+ await Promise.all([
918
+ this.loadSessionModels(this.sessionId),
919
+ settings.conversation().then((section) => {
920
+ this.busyEnter = section.busyEnter;
921
+ }).catch(() => {
922
+ this.busyEnter = DEFAULT_BUSY_ENTER;
923
+ }),
924
+ ]);
925
+ } else {
926
+ try {
927
+ this.models = await settings.models();
928
+ this.modelError = "";
929
+ } catch (err) {
930
+ this.modelError = err instanceof Error ? err.message : String(err);
931
+ }
482
932
  }
483
933
  try {
484
934
  const voice = await settings.voice();
@@ -487,6 +937,34 @@ export default {
487
937
  this.voiceLanguage = "";
488
938
  }
489
939
  },
940
+ async loadSessionModels(sessionId) {
941
+ const gen = ++this.modelGen;
942
+ if (!sessionId) {
943
+ this.models = null;
944
+ return;
945
+ }
946
+ this.modelError = "";
947
+ const loaded = await this.runtime.sessionModels(sessionId);
948
+ if (gen !== this.modelGen || sessionId !== this.sessionId) return;
949
+ if (!loaded.ok) {
950
+ this.models = null;
951
+ this.modelError = loaded.error?.message || loaded.error?.code || "models";
952
+ return;
953
+ }
954
+ const value = loaded.value ?? {};
955
+ this.models = {
956
+ default: value.current,
957
+ routable: value.routable !== false,
958
+ failures: value.failures ?? [],
959
+ models: (value.groups ?? []).flatMap((group) => (
960
+ (group.models ?? []).map((model) => ({
961
+ ...model,
962
+ provider: group.id,
963
+ providerName: group.name || group.id,
964
+ }))
965
+ )),
966
+ };
967
+ },
490
968
  openEffortSheet() {
491
969
  if (this.effortDisabled) return;
492
970
  this.effortSheet = true;
@@ -503,9 +981,29 @@ export default {
503
981
  // storage may be blocked
504
982
  }
505
983
  },
506
- chooseEffort(id) {
984
+ async chooseEffort(id) {
507
985
  this.effortSheet = false;
508
986
  if (!this.modelCurrent) return;
987
+ if (this.chatDevice === "desktop") {
988
+ if (id === this.effortId || this.modelBusy) return;
989
+ this.modelPending = `${this.modelCurrent}\u0000${id ?? ""}`;
990
+ const current = this.models?.default;
991
+ try {
992
+ const selected = await this.runtime.selectModel({
993
+ provider: current.provider,
994
+ model: current.model,
995
+ ...(id === undefined ? {} : { reasoningEffort: id }),
996
+ });
997
+ if (!selected.ok) {
998
+ this.modelError = selected.error?.message || selected.error?.code || "model";
999
+ return;
1000
+ }
1001
+ this.models = { ...this.models, default: selected.value?.selected ?? current };
1002
+ } finally {
1003
+ this.modelPending = "";
1004
+ }
1005
+ return;
1006
+ }
509
1007
  this.effortByModel = { ...this.effortByModel, [this.modelCurrent]: id ?? "" };
510
1008
  this.persistEffort();
511
1009
  },
@@ -523,26 +1021,121 @@ export default {
523
1021
  this.modelPending = key;
524
1022
  this.modelError = "";
525
1023
  try {
526
- this.models = await this.runtime.settings.setDefaultModel({
527
- provider: model.provider,
528
- model: model.id,
529
- });
1024
+ if (this.chatDevice === "desktop") {
1025
+ const reasoning = reasoningOfModel(model);
1026
+ const selection = {
1027
+ provider: model.provider,
1028
+ model: model.id,
1029
+ ...(reasoning?.defaultEffort === undefined
1030
+ ? {}
1031
+ : { reasoningEffort: reasoning.defaultEffort }),
1032
+ };
1033
+ const selected = await this.runtime.selectModel(selection);
1034
+ if (!selected.ok) throw new Error(selected.error?.message || selected.error?.code || "model");
1035
+ this.models = { ...this.models, default: selected.value?.selected ?? selection };
1036
+ } else {
1037
+ this.models = await this.runtime.settings.setDefaultModel({
1038
+ provider: model.provider,
1039
+ model: model.id,
1040
+ });
1041
+ }
530
1042
  } catch (err) {
531
1043
  this.modelError = err instanceof Error ? err.message : String(err);
532
1044
  } finally {
533
1045
  this.modelPending = "";
534
1046
  }
535
1047
  },
1048
+ patchDraftImage(sessionId, imageId, patch) {
1049
+ const rows = sessionId === this.sessionId
1050
+ ? this.draftImages
1051
+ : (this.imagesBySession[sessionId] ?? []);
1052
+ const index = rows.findIndex((row) => row.id === imageId);
1053
+ if (index < 0) return;
1054
+ const previous = rows[index];
1055
+ if (patch.previewUrl && previous.previewUrl?.startsWith("blob:")) {
1056
+ URL.revokeObjectURL(previous.previewUrl);
1057
+ }
1058
+ const next = [...rows];
1059
+ next[index] = { ...previous, ...patch };
1060
+ this.imagesBySession = { ...this.imagesBySession, [sessionId]: next };
1061
+ if (sessionId === this.sessionId) this.draftImages = next;
1062
+ },
1063
+ async uploadDraftImage(sessionId, imageId) {
1064
+ const rows = sessionId === this.sessionId
1065
+ ? this.draftImages
1066
+ : (this.imagesBySession[sessionId] ?? []);
1067
+ const image = rows.find((row) => row.id === imageId);
1068
+ if (!image?.file || draftImageStatus(image) !== "uploading") return;
1069
+ const controller = new AbortController();
1070
+ this.imageUploadControllers.set(imageId, controller);
1071
+ try {
1072
+ const result = await this.runtime.upload(
1073
+ image.file,
1074
+ { signal: controller.signal, requestId: imageId },
1075
+ sessionId,
1076
+ );
1077
+ if (controller.signal.aborted) return;
1078
+ this.patchDraftImage(sessionId, imageId, {
1079
+ status: "ready",
1080
+ path: result.path,
1081
+ previewUrl: this.runtime.mediaUrl(result.path),
1082
+ });
1083
+ } catch (err) {
1084
+ if (controller.signal.aborted) return;
1085
+ this.patchDraftImage(sessionId, imageId, {
1086
+ status: "failed",
1087
+ error: err instanceof Error ? err.message : "file_upload_failed",
1088
+ });
1089
+ } finally {
1090
+ this.imageUploadControllers.delete(imageId);
1091
+ }
1092
+ },
536
1093
  attachFiles(files) {
537
1094
  const sessionId = this.sessionId;
538
1095
  if (!sessionId || !files?.length) return;
539
- const incoming = [...files];
540
- const { accepted, oversized } = partitionDocumentsBySize(incoming, DEFAULT_MAX_UPLOAD_BYTES);
1096
+ const { images, documents } = splitComposerFiles(files);
1097
+ const added = images.map((file) => createDraftImageEntry(file));
1098
+ if (added.length) {
1099
+ this.draftImages = [...this.draftImages, ...added];
1100
+ this.imagesBySession = { ...this.imagesBySession, [sessionId]: this.draftImages };
1101
+ for (const image of added) void this.uploadDraftImage(sessionId, image.id);
1102
+ }
1103
+ const { accepted, oversized } = partitionDocumentsBySize(documents, DEFAULT_MAX_UPLOAD_BYTES);
541
1104
  for (const file of oversized) this.intake.reportFailure(sessionId, file, "file_too_large");
542
1105
  void this.intake.uploadFiles(sessionId, accepted, (paths) => {
543
- this.draft = appendDraftMentions(this.draft, paths);
1106
+ const previous = sessionId === this.sessionId
1107
+ ? this.draft
1108
+ : this.draftBySession[sessionId] ?? "";
1109
+ const next = appendDraftMentions(previous, paths);
1110
+ this.draftBySession = { ...this.draftBySession, [sessionId]: next };
1111
+ if (sessionId === this.sessionId) this.draft = next;
544
1112
  });
545
1113
  },
1114
+ removeDraftImage(id) {
1115
+ this.imageUploadControllers.get(id)?.abort();
1116
+ this.imageUploadControllers.delete(id);
1117
+ const image = this.draftImages.find((row) => row.id === id);
1118
+ if (image?.previewUrl?.startsWith("blob:")) URL.revokeObjectURL(image.previewUrl);
1119
+ this.draftImages = this.draftImages.filter((row) => row.id !== id);
1120
+ this.imagesBySession = { ...this.imagesBySession, [this.sessionId || "@pending"]: this.draftImages };
1121
+ },
1122
+ releaseDraftImages() {
1123
+ const seen = new Set();
1124
+ for (const rows of Object.values(this.imagesBySession)) {
1125
+ for (const image of rows) {
1126
+ if (seen.has(image.id)) continue;
1127
+ seen.add(image.id);
1128
+ if (image.previewUrl?.startsWith("blob:")) URL.revokeObjectURL(image.previewUrl);
1129
+ }
1130
+ }
1131
+ for (const image of this.draftImages) {
1132
+ if (!seen.has(image.id) && image.previewUrl?.startsWith("blob:")) {
1133
+ URL.revokeObjectURL(image.previewUrl);
1134
+ }
1135
+ }
1136
+ this.draftImages = [];
1137
+ this.imagesBySession = {};
1138
+ },
546
1139
  onHoldStart() {
547
1140
  if (this.voicePhase === "idle" || this.voicePhase === "error") this.voiceCap?.start();
548
1141
  },
@@ -559,7 +1152,11 @@ export default {
559
1152
  return;
560
1153
  }
561
1154
  this.panel = "history";
562
- this.runtime.refreshSessions();
1155
+ // Refresh after the sheet slide so list work does not block the first paint.
1156
+ window.setTimeout(() => {
1157
+ if (this.panel !== "history") return;
1158
+ void Promise.all([this.runtime.refreshSessions(), this.runtime.refreshWorkspaces()]);
1159
+ }, 320);
563
1160
  },
564
1161
  pickSession(sessionId) {
565
1162
  this.panel = "";
@@ -568,63 +1165,206 @@ export default {
568
1165
  this.previews = {};
569
1166
  this.runtime.openSession(sessionId);
570
1167
  },
571
- newChat() {
1168
+ newChat(workspaceId = null) {
572
1169
  this.panel = "";
573
1170
  this.pendingUser = "";
574
1171
  this.previews = {};
575
- this.runtime.createSession();
1172
+ this.draftBySession = { ...this.draftBySession, "@pending": "" };
1173
+ this.imagesBySession = { ...this.imagesBySession, "@pending": [] };
1174
+ const scoped = workspaceId || (this.chatDevice === "desktop" ? "" : this.currentWorkspaceId) || null;
1175
+ this.runtime.createSession(scoped);
1176
+ },
1177
+ pickWorkspace(workspaceId) {
1178
+ if (!workspaceId || workspaceId === this.currentWorkspaceId) return;
1179
+ this.newChat(workspaceId);
576
1180
  },
577
- async send() {
1181
+ refreshWorkspaces() {
1182
+ return this.runtime.refreshWorkspaces();
1183
+ },
1184
+ async openWorkspaceBrowser() {
1185
+ this.directory = {
1186
+ open: true,
1187
+ loading: true,
1188
+ listing: null,
1189
+ error: "",
1190
+ creating: false,
1191
+ creatingFolder: false,
1192
+ showHidden: false,
1193
+ selectPath: "",
1194
+ };
1195
+ await this.browseDirectory();
1196
+ },
1197
+ closeWorkspaceBrowser() {
1198
+ if (this.directory.creating || this.directory.creatingFolder) return;
1199
+ this.directory = { ...this.directory, open: false, error: "", selectPath: "" };
1200
+ },
1201
+ toggleDirectoryHidden(showHidden) {
1202
+ this.directory = { ...this.directory, showHidden: Boolean(showHidden) };
1203
+ },
1204
+ async createDirectoryFolder(name) {
1205
+ const parent = this.directory.listing?.path;
1206
+ if (!parent || !name?.trim() || this.directory.creatingFolder) return;
1207
+ this.directory = { ...this.directory, creatingFolder: true, error: "" };
1208
+ const created = await this.runtime.createDirectory(parent, name.trim());
1209
+ if (!this.directory.open) return;
1210
+ if (!created.ok) {
1211
+ this.directory = {
1212
+ ...this.directory,
1213
+ creatingFolder: false,
1214
+ error: created.error?.message || created.error?.code || "directory",
1215
+ };
1216
+ return;
1217
+ }
1218
+ const selectPath = created.value?.path || "";
1219
+ await this.browseDirectory(parent);
1220
+ if (!this.directory.open) return;
1221
+ this.directory = {
1222
+ ...this.directory,
1223
+ creatingFolder: false,
1224
+ selectPath,
1225
+ error: "",
1226
+ };
1227
+ },
1228
+ async browseDirectory(path) {
1229
+ this.directory = { ...this.directory, loading: true, error: "" };
1230
+ const listed = await this.runtime.listDirectory(path);
1231
+ if (!this.directory.open) return;
1232
+ if (!listed.ok) {
1233
+ this.directory = {
1234
+ ...this.directory,
1235
+ loading: false,
1236
+ error: listed.error?.message || listed.error?.code || "directory",
1237
+ };
1238
+ return;
1239
+ }
1240
+ this.directory = {
1241
+ ...this.directory,
1242
+ loading: false,
1243
+ listing: listed.value,
1244
+ error: "",
1245
+ };
1246
+ },
1247
+ async chooseWorkspaceDirectory(path) {
1248
+ if (!path || this.directory.creating) return;
1249
+ this.directory = { ...this.directory, creating: true, error: "" };
1250
+ const created = await this.runtime.createWorkspace(path);
1251
+ if (!created.ok) {
1252
+ this.directory = {
1253
+ ...this.directory,
1254
+ creating: false,
1255
+ error: created.error?.message || created.error?.code || "workspace",
1256
+ };
1257
+ return;
1258
+ }
1259
+ const workspaceId = created.value?.workspace?.workspaceId;
1260
+ this.directory = { ...this.directory, open: false, creating: false };
1261
+ if (workspaceId) this.newChat(workspaceId);
1262
+ },
1263
+ async renameWorkspace(workspaceId, title) {
1264
+ if (!workspaceId || !title?.trim()) return;
1265
+ await this.runtime.renameWorkspace(workspaceId, title.trim());
1266
+ },
1267
+ async deleteWorkspace(workspaceId) {
1268
+ if (!workspaceId) return;
1269
+ await this.runtime.deleteWorkspace(workspaceId);
1270
+ },
1271
+ moveWorkspace(workspaceId, beforeWorkspaceId) {
1272
+ if (!workspaceId) return;
1273
+ return this.runtime.insertWorkspaceBefore(workspaceId, beforeWorkspaceId);
1274
+ },
1275
+ moveSession(workspaceId, sessionId, beforeSessionId) {
1276
+ if (!workspaceId || !sessionId) return;
1277
+ return this.runtime.insertSessionBefore(workspaceId, sessionId, beforeSessionId);
1278
+ },
1279
+ renameSession(sessionId, title) {
1280
+ if (!sessionId || !title?.trim()) return;
1281
+ return this.runtime.renameSession(sessionId, title.trim());
1282
+ },
1283
+ forkSession(sessionId) {
1284
+ if (!sessionId) return;
1285
+ this.pendingUser = "";
1286
+ this.previews = {};
1287
+ return this.runtime.forkSession(sessionId);
1288
+ },
1289
+ archiveSession(sessionId) {
1290
+ if (!sessionId) return;
1291
+ return this.runtime.archiveSession(sessionId);
1292
+ },
1293
+ async send(mode = "queue") {
578
1294
  const text = this.draft.trim();
579
1295
  if (!this.canSend) return;
1296
+ const sessionId = this.sessionId;
1297
+ const images = this.draftImages.filter((row) => draftImageStatus(row) === "ready");
1298
+ const wasRunning = this.running;
580
1299
  this.sending = true;
581
1300
  this.draft = "";
582
- this.pendingUser = text;
1301
+ this.draftImages = [];
1302
+ this.draftBySession = { ...this.draftBySession, [sessionId]: "" };
1303
+ this.imagesBySession = { ...this.imagesBySession, [sessionId]: [] };
1304
+ this.pendingUser = wasRunning ? "" : text;
583
1305
  this.runtime.pinToBottom();
584
1306
  this.pinLog();
585
1307
  try {
586
- const sent = await this.runtime.send(text);
1308
+ const content = [
1309
+ ...(text ? [{ type: "text", text }] : []),
1310
+ ...(await Promise.all(images.map((row) => imageData(row.file)))),
1311
+ ];
1312
+ const sent = await this.runtime.send(content, mode === "steer" ? "steer" : "queue");
587
1313
  if (!sent.ok) {
588
1314
  this.pendingUser = "";
589
- this.draft = text;
1315
+ this.draftBySession = { ...this.draftBySession, [sessionId]: text };
1316
+ this.imagesBySession = { ...this.imagesBySession, [sessionId]: images };
1317
+ if (this.sessionId === sessionId) {
1318
+ this.draft = text;
1319
+ this.draftImages = images;
1320
+ }
590
1321
  this.failed = sent.error?.message || sent.error?.code || "send";
1322
+ } else {
1323
+ for (const image of images) URL.revokeObjectURL(image.previewUrl);
591
1324
  }
1325
+ } catch (err) {
1326
+ this.pendingUser = "";
1327
+ this.draftBySession = { ...this.draftBySession, [sessionId]: text };
1328
+ this.imagesBySession = { ...this.imagesBySession, [sessionId]: images };
1329
+ if (this.sessionId === sessionId) {
1330
+ this.draft = text;
1331
+ this.draftImages = images;
1332
+ }
1333
+ this.failed = err instanceof Error ? err.message : "attachment";
592
1334
  } finally {
593
1335
  this.sending = false;
594
1336
  }
595
1337
  },
1338
+ async updateQueue(itemId, action) {
1339
+ if (!itemId || this.queueBusy) return;
1340
+ this.queueBusy = itemId;
1341
+ try {
1342
+ const changed = await this.runtime.updateQueue(itemId, action);
1343
+ if (!changed.ok) {
1344
+ this.failed = changed.error?.message || changed.error?.code || "queue";
1345
+ }
1346
+ } finally {
1347
+ this.queueBusy = "";
1348
+ }
1349
+ },
1350
+ async steerQueue() {
1351
+ const rows = this.queue.filter((row) => row.placement === "queued");
1352
+ for (const row of rows) {
1353
+ await this.updateQueue(row.id, { kind: "steer" });
1354
+ }
1355
+ },
1356
+ async stop() {
1357
+ if (!this.running || this.stopping) return;
1358
+ this.stopping = true;
1359
+ try {
1360
+ const stopped = await this.runtime.cancel();
1361
+ if (!stopped.ok) {
1362
+ this.failed = stopped.error?.message || stopped.error?.code || "cancel";
1363
+ }
1364
+ } finally {
1365
+ this.stopping = false;
1366
+ }
1367
+ },
596
1368
  },
597
1369
  };
598
1370
  </script>
599
-
600
- <style scoped>
601
- .lares-shell__status,
602
- .lares-shell__hint {
603
- margin: 0 20px;
604
- font-size: 13px;
605
- color: var(--q-ink-3);
606
- }
607
-
608
- .lares-shell__status {
609
- color: var(--q-orange-default);
610
- }
611
-
612
- .lares-shell__retry {
613
- align-self: flex-start;
614
- margin: 0 20px;
615
- border: 0;
616
- border-radius: 8px;
617
- padding: 8px 12px;
618
- background: var(--q-blue-alpha);
619
- color: var(--q-blue-default);
620
- }
621
-
622
- .lares-shell__heading {
623
- margin: 0;
624
- padding: 12px 16px 4px;
625
- color: var(--q-ink-3);
626
- font-size: 12px;
627
- font-weight: 500;
628
- line-height: 16px;
629
- }
630
- </style>