@olares/lares-mobile 0.15.5 → 0.15.7

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