@olares/lares-mobile 0.15.5 → 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 +917 -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 +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,110 +1,103 @@
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>
@@ -116,24 +109,48 @@ import { createT } from "./i18n.js";
116
109
  import { failText, withPendingUser } from "./chat/format.js";
117
110
  import { createVoiceCapture } from "./chat/voice.js";
118
111
  import { appendDraftMentions } from "@olares/lares-core/files/mention";
119
- 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";
120
119
  import { DEFAULT_MAX_UPLOAD_BYTES } from "@olares/lares-core/files/limits";
121
120
  import { groupModelsByProvider, effortMenuRows, reasoningOfModel, currentEffortId, selectionKey } from "@olares/lares-core/router/session-model";
122
121
  import { messageFromCode } from "@olares/lares-core/i18n/t";
123
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";
124
130
  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";
131
+ import LaresMobileShell from "./mobile/MobileShell.vue";
132
+ import LaresDesktopShell from "./desktop/DesktopShell.vue";
132
133
  import "./styles.css";
133
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
+
134
151
  export default {
135
152
  name: "LaresApp",
136
- components: { LaresChatBar, LaresHistoryPanel, LaresTranscript, LaresComposer, LaresPreview, LaresSheet, LaresSettingRow },
153
+ components: { LaresMobileShell, LaresDesktopShell },
137
154
  props: {
138
155
  locale: { type: String, default: "en" },
139
156
  device: { type: String, default: "mobile" },
@@ -148,18 +165,26 @@ export default {
148
165
  data() {
149
166
  return {
150
167
  sending: false,
168
+ stopping: false,
151
169
  starting: false,
152
170
  hostGen: 0,
171
+ modelGen: 0,
153
172
  running: false,
154
173
  sessionId: "",
155
174
  draft: "",
175
+ draftBySession: {},
176
+ imagesBySession: {},
156
177
  failed: "",
157
178
  error: "",
158
179
  items: [],
159
180
  pendingUser: "",
160
181
  unsub: null,
161
182
  previews: {},
162
- preview: { path: "", status: "idle", data: null, error: "" },
183
+ previewTabs: [],
184
+ previewLru: [],
185
+ previewPath: "",
186
+ previewMode: "chat",
187
+ previewContents: {},
163
188
  panel: "",
164
189
  sessions: [],
165
190
  sessionsReady: false,
@@ -171,6 +196,7 @@ export default {
171
196
  effortSheet: false,
172
197
  effortByModel: {},
173
198
  upload: { pending: 0, failures: [] },
199
+ draftImages: [],
174
200
  voicePhase: "idle",
175
201
  voiceElapsed: 0,
176
202
  voiceError: "",
@@ -178,6 +204,42 @@ export default {
178
204
  intakeUnsub: null,
179
205
  question: null,
180
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
+ },
181
243
  };
182
244
  },
183
245
  computed: {
@@ -198,8 +260,116 @@ export default {
198
260
  chatDevice() {
199
261
  return resolveChatDevice(this.device);
200
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
+ },
201
366
  canSend() {
202
- 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);
203
373
  },
204
374
  sessionTitle() {
205
375
  const row = this.sessions.find((item) => item.sessionId === this.sessionId);
@@ -220,6 +390,9 @@ export default {
220
390
  return reasoningOfModel(this.currentListedModel);
221
391
  },
222
392
  effortId() {
393
+ if (this.chatDevice === "desktop") {
394
+ return currentEffortId(this.models?.default, this.reasoning);
395
+ }
223
396
  if (!(this.modelCurrent in this.effortByModel)) {
224
397
  return currentEffortId(this.models?.default, this.reasoning);
225
398
  }
@@ -241,6 +414,11 @@ export default {
241
414
  failText() {
242
415
  return failText(this.t, this.failed, this.error);
243
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
+ },
244
422
  previewMediaSrc() {
245
423
  const data = this.preview.data;
246
424
  if (!data) return "";
@@ -272,6 +450,9 @@ export default {
272
450
  modelBusy() {
273
451
  return Boolean(this.modelPending) || (this.modelSheet && !this.models);
274
452
  },
453
+ modelBlocked() {
454
+ return this.chatDevice === "desktop" && this.models?.routable === false;
455
+ },
275
456
  voiceErrorText() {
276
457
  return this.voiceError ? messageFromCode(this.t, this.voiceError, "error.voice_failed") : "";
277
458
  },
@@ -291,7 +472,10 @@ export default {
291
472
  },
292
473
  },
293
474
  created() {
475
+ // Per-path request tokens: reactive state would re-render on every read.
476
+ this.previewGen = new Map();
294
477
  this.intake = new FileIntake((file, sessionId, options) => this.runtime.upload(file, options, sessionId));
478
+ this.imageUploadControllers = new Map();
295
479
  this.voiceCap = createVoiceCapture({
296
480
  transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
297
481
  getDraft: () => this.draft,
@@ -312,8 +496,19 @@ export default {
312
496
  } catch {
313
497
  this.effortByModel = {};
314
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();
315
507
  },
316
508
  mounted() {
509
+ this.conversationUnsub = subscribeConversationSettings((section) => {
510
+ this.busyEnter = section.busyEnter;
511
+ });
317
512
  this.retry();
318
513
  },
319
514
  activated() {
@@ -324,9 +519,14 @@ export default {
324
519
  },
325
520
  beforeUnmount() {
326
521
  this.captureLog();
522
+ this.noteSeen(this.sessionId);
523
+ for (const controller of this.imageUploadControllers?.values?.() ?? []) controller.abort();
524
+ this.imageUploadControllers?.clear?.();
327
525
  this.voiceCap?.dispose();
526
+ this.conversationUnsub?.();
328
527
  this.intakeUnsub?.();
329
528
  if (this.sessionId) this.intake?.cancelSession(this.sessionId);
529
+ this.releaseDraftImages();
330
530
  this.unsub?.();
331
531
  this.unsub = null;
332
532
  },
@@ -344,18 +544,36 @@ export default {
344
544
  return this.runtime.sticking();
345
545
  },
346
546
  restoreLog() {
347
- this.$refs.transcript?.restoreLog?.();
547
+ this.$refs.shell?.restoreLog?.();
348
548
  },
349
549
  captureLog() {
350
- this.$refs.transcript?.captureLog?.();
550
+ this.$refs.shell?.captureLog?.();
351
551
  },
352
552
  pinLog() {
353
- this.$refs.transcript?.scheduleLogPin?.();
553
+ this.$refs.shell?.pinLog?.();
354
554
  },
355
555
  applySnap(snap) {
356
- 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);
357
572
  this.pendingUser = "";
358
573
  this.previews = {};
574
+ this.resetPreview();
575
+ this.commands = null;
576
+ this.commandsError = "";
359
577
  }
360
578
  this.sessionId = snap.sessionId;
361
579
  this.items = snap.items;
@@ -364,14 +582,111 @@ export default {
364
582
  this.error = snap.error;
365
583
  this.sessions = snap.sessions ?? this.sessions;
366
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;
367
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 ?? [];
368
597
  this.question = snap.question || null;
369
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
+ }
370
604
  if (this.pendingUser && snap.items.some((row) => row.type === "user" && row.text === this.pendingUser)) {
371
605
  this.pendingUser = "";
372
606
  }
373
607
  this.pinLog();
374
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
+ },
375
690
  async hydrateFiles(paths) {
376
691
  const missing = [...new Set(paths)].filter((path) => !(path in this.previews));
377
692
  if (!missing.length) return;
@@ -400,30 +715,122 @@ export default {
400
715
  this.questionBusy = false;
401
716
  }
402
717
  },
403
- async openFile(path) {
404
- 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
+ }
405
808
  try {
406
809
  const data = await this.runtime.preview(path);
810
+ if (this.previewGen.get(path) !== gen) return;
407
811
  this.previews = { ...this.previews, [path]: data };
408
- 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: "" });
409
815
  } catch (err) {
410
- this.preview = {
411
- path,
816
+ this.setPreviewContent(path, gen, {
412
817
  status: "error",
413
818
  data: null,
414
819
  error: err instanceof Error ? err.message : "file_preview_failed",
415
- };
820
+ });
416
821
  }
417
822
  },
418
- closePreview() {
419
- 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 };
420
826
  },
421
827
  async retry() {
422
828
  const gen = ++this.hostGen;
423
829
  this.starting = true;
424
830
  try {
425
- await this.runtime.start();
831
+ await this.runtime.start({ workspaces: this.chatDevice === "desktop" });
426
832
  if (gen !== this.hostGen) return;
833
+ if (this.chatDevice !== "desktop") await this.runtime.refreshWorkspaces();
427
834
  await this.loadComposerMeta();
428
835
  } finally {
429
836
  if (gen === this.hostGen) this.starting = false;
@@ -438,6 +845,9 @@ export default {
438
845
  },
439
846
  switchHost() {
440
847
  this.voiceCap?.stop?.(true);
848
+ for (const controller of this.imageUploadControllers.values()) controller.abort();
849
+ this.imageUploadControllers.clear();
850
+ this.releaseDraftImages();
441
851
  this.intakeUnsub?.();
442
852
  if (this.sessionId) this.intake?.cancelSession(this.sessionId);
443
853
  this.sessionId = "";
@@ -445,7 +855,7 @@ export default {
445
855
  this.sessions = [];
446
856
  this.sessionsReady = false;
447
857
  this.previews = {};
448
- this.preview = { path: "", status: "idle", data: null, error: "" };
858
+ this.resetPreview();
449
859
  this.pendingUser = "";
450
860
  this.failed = "";
451
861
  this.error = "";
@@ -456,6 +866,34 @@ export default {
456
866
  this.panel = "";
457
867
  this.question = null;
458
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
+ };
459
897
  this.bindIntake("");
460
898
  this.bindRuntime();
461
899
  this.retry();
@@ -475,11 +913,22 @@ export default {
475
913
  async loadComposerMeta() {
476
914
  const settings = this.runtime.settings;
477
915
  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);
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
+ }
483
932
  }
484
933
  try {
485
934
  const voice = await settings.voice();
@@ -488,6 +937,34 @@ export default {
488
937
  this.voiceLanguage = "";
489
938
  }
490
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
+ },
491
968
  openEffortSheet() {
492
969
  if (this.effortDisabled) return;
493
970
  this.effortSheet = true;
@@ -504,9 +981,29 @@ export default {
504
981
  // storage may be blocked
505
982
  }
506
983
  },
507
- chooseEffort(id) {
984
+ async chooseEffort(id) {
508
985
  this.effortSheet = false;
509
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
+ }
510
1007
  this.effortByModel = { ...this.effortByModel, [this.modelCurrent]: id ?? "" };
511
1008
  this.persistEffort();
512
1009
  },
@@ -524,26 +1021,121 @@ export default {
524
1021
  this.modelPending = key;
525
1022
  this.modelError = "";
526
1023
  try {
527
- this.models = await this.runtime.settings.setDefaultModel({
528
- provider: model.provider,
529
- model: model.id,
530
- });
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
+ }
531
1042
  } catch (err) {
532
1043
  this.modelError = err instanceof Error ? err.message : String(err);
533
1044
  } finally {
534
1045
  this.modelPending = "";
535
1046
  }
536
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
+ },
537
1093
  attachFiles(files) {
538
1094
  const sessionId = this.sessionId;
539
1095
  if (!sessionId || !files?.length) return;
540
- const incoming = [...files];
541
- 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);
542
1104
  for (const file of oversized) this.intake.reportFailure(sessionId, file, "file_too_large");
543
1105
  void this.intake.uploadFiles(sessionId, accepted, (paths) => {
544
- 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;
545
1112
  });
546
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
+ },
547
1139
  onHoldStart() {
548
1140
  if (this.voicePhase === "idle" || this.voicePhase === "error") this.voiceCap?.start();
549
1141
  },
@@ -560,7 +1152,11 @@ export default {
560
1152
  return;
561
1153
  }
562
1154
  this.panel = "history";
563
- 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);
564
1160
  },
565
1161
  pickSession(sessionId) {
566
1162
  this.panel = "";
@@ -569,63 +1165,206 @@ export default {
569
1165
  this.previews = {};
570
1166
  this.runtime.openSession(sessionId);
571
1167
  },
572
- newChat() {
1168
+ newChat(workspaceId = null) {
573
1169
  this.panel = "";
574
1170
  this.pendingUser = "";
575
1171
  this.previews = {};
576
- 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);
577
1180
  },
578
- 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") {
579
1294
  const text = this.draft.trim();
580
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;
581
1299
  this.sending = true;
582
1300
  this.draft = "";
583
- this.pendingUser = text;
1301
+ this.draftImages = [];
1302
+ this.draftBySession = { ...this.draftBySession, [sessionId]: "" };
1303
+ this.imagesBySession = { ...this.imagesBySession, [sessionId]: [] };
1304
+ this.pendingUser = wasRunning ? "" : text;
584
1305
  this.runtime.pinToBottom();
585
1306
  this.pinLog();
586
1307
  try {
587
- 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");
588
1313
  if (!sent.ok) {
589
1314
  this.pendingUser = "";
590
- 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
+ }
591
1321
  this.failed = sent.error?.message || sent.error?.code || "send";
1322
+ } else {
1323
+ for (const image of images) URL.revokeObjectURL(image.previewUrl);
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;
592
1332
  }
1333
+ this.failed = err instanceof Error ? err.message : "attachment";
593
1334
  } finally {
594
1335
  this.sending = false;
595
1336
  }
596
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
+ },
597
1368
  },
598
1369
  };
599
1370
  </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>