@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
@@ -0,0 +1,871 @@
1
+ <template>
2
+ <form class="desktop-composer" @submit.prevent>
3
+ <div class="desktop-composer__model-row">
4
+ <LaresPcPopover v-model="modelOpen" :width="260" placement="top-end">
5
+ <template #trigger="{ toggle }">
6
+ <button
7
+ type="button"
8
+ class="desktop-composer__model"
9
+ :disabled="state.modelBusy"
10
+ aria-haspopup="menu"
11
+ :aria-expanded="modelOpen"
12
+ @mousedown.prevent
13
+ @click="toggle"
14
+ >
15
+ <span>{{ state.modelLabel }}</span>
16
+ <ChevronIcon />
17
+ </button>
18
+ </template>
19
+ <div class="desktop-composer__menu">
20
+ <p v-if="state.modelError" class="desktop-composer__menu-status">{{ state.modelError }}</p>
21
+ <p v-else-if="!state.models" class="desktop-composer__menu-status">{{ t("agent.loading") }}</p>
22
+ <template v-for="group in state.modelGroups" :key="group.provider">
23
+ <p class="desktop-composer__menu-label">{{ group.name || group.provider }}</p>
24
+ <button
25
+ v-for="model in group.models"
26
+ :key="modelKey(model)"
27
+ type="button"
28
+ class="desktop-composer__option"
29
+ :data-selected="modelKey(model) === state.modelCurrent"
30
+ @click="chooseModel(model)"
31
+ >
32
+ <span>{{ model.name }}</span>
33
+ <CheckIcon v-if="modelKey(model) === state.modelCurrent" />
34
+ </button>
35
+ </template>
36
+ </div>
37
+ </LaresPcPopover>
38
+ <LaresPcPopover v-if="!state.effortDisabled" v-model="effortOpen" :width="180" placement="top-end">
39
+ <template #trigger="{ toggle }">
40
+ <button
41
+ type="button"
42
+ class="desktop-composer__model"
43
+ :disabled="state.modelBusy"
44
+ aria-haspopup="menu"
45
+ :aria-expanded="effortOpen"
46
+ @mousedown.prevent
47
+ @click="toggle"
48
+ >
49
+ <span>{{ state.effortLabel }}</span>
50
+ <ChevronIcon />
51
+ </button>
52
+ </template>
53
+ <div class="desktop-composer__menu">
54
+ <button
55
+ v-for="row in state.effortRows"
56
+ :key="row.key"
57
+ type="button"
58
+ class="desktop-composer__option"
59
+ :data-selected="row.id === state.effortId"
60
+ @click="chooseEffort(row.id)"
61
+ >
62
+ <span>{{ effortName(row) }}</span>
63
+ <CheckIcon v-if="row.id === state.effortId" />
64
+ </button>
65
+ </div>
66
+ </LaresPcPopover>
67
+ </div>
68
+
69
+ <LaresDesktopTodos :rows="state.todos || []" :t="t" />
70
+ <LaresDesktopGoal
71
+ :projection="state.goal"
72
+ :busy="state.goalBusy"
73
+ :t="t"
74
+ @action="(kind, value) => $emit('goal-action', kind, value)"
75
+ />
76
+ <LaresDesktopQueue
77
+ :items="state.queue"
78
+ :running="state.running"
79
+ :busy="state.queueBusy"
80
+ :t="t"
81
+ @action="(itemId, action) => $emit('queue-action', itemId, action)"
82
+ />
83
+
84
+ <Transition name="lares-panel" mode="out-in">
85
+ <div v-if="state.approval" key="approval" class="desktop-composer__approval">
86
+ <div class="desktop-composer__approval-strip">
87
+ <span aria-hidden="true" />
88
+ {{ t("approval.waiting") }}
89
+ </div>
90
+ <div class="desktop-composer__approval-body">
91
+ {{ state.approval.reason || t("approval.escalation", { toolName: state.approval.toolName }) }}
92
+ </div>
93
+ <div class="desktop-composer__approval-actions">
94
+ <button type="button" :disabled="state.approvalBusy" @click="$emit('answer-approval', 'rejected')">
95
+ {{ t("approval.reject") }}
96
+ </button>
97
+ <button type="button" :disabled="state.approvalBusy" @click="$emit('answer-approval', 'allowed-once')">
98
+ {{ t("approval.allowOnce") }}
99
+ </button>
100
+ </div>
101
+ </div>
102
+ <LaresDesktopQuestions
103
+ v-else-if="state.question?.questions?.length"
104
+ key="questions"
105
+ :questions="state.question.questions"
106
+ :busy="state.questionBusy"
107
+ :t="t"
108
+ @answer="$emit('answer-questions', $event)"
109
+ />
110
+ <div
111
+ v-else
112
+ key="composer"
113
+ class="desktop-composer__card"
114
+ data-composer-card
115
+ @dragover.prevent
116
+ @drop.prevent="onDrop"
117
+ @paste="onPaste"
118
+ >
119
+ <div
120
+ v-if="commandTyped && commandsOpen"
121
+ ref="commandsMenu"
122
+ class="desktop-composer__reference-menu"
123
+ role="listbox"
124
+ >
125
+ <p class="desktop-composer__reference-section">{{ t("desktop.commands") }}</p>
126
+ <p v-if="state.commandsError" class="desktop-composer__reference-status">{{ state.commandsError }}</p>
127
+ <p v-else-if="!state.commands" class="desktop-composer__reference-status">{{ t("agent.loading") }}</p>
128
+ <p v-else-if="visibleCommands.length === 0" class="desktop-composer__reference-status">{{ t("desktop.noCommands") }}</p>
129
+ <div v-else class="desktop-composer__reference-viewport">
130
+ <button
131
+ v-for="(command, index) in visibleCommands"
132
+ :key="command.name"
133
+ type="button"
134
+ class="desktop-composer__reference-item desktop-composer__command-item"
135
+ role="option"
136
+ :aria-selected="index === commandIndex"
137
+ :data-selected="index === commandIndex"
138
+ @mousedown.prevent
139
+ @mouseenter="commandIndex = index"
140
+ @click="pickCommand(command)"
141
+ >
142
+ <span class="desktop-composer__reference-name">/{{ command.name }}</span>
143
+ <span class="desktop-composer__reference-path">{{ command.description || command.hint }}</span>
144
+ </button>
145
+ </div>
146
+ </div>
147
+ <div v-if="referenceOpen" ref="referenceMenu" class="desktop-composer__reference-menu" role="listbox">
148
+ <p v-if="state.referencesError" class="desktop-composer__reference-status">{{ state.referencesError }}</p>
149
+ <p v-else-if="state.referencesLoading" class="desktop-composer__reference-status">{{ t("agent.loading") }}</p>
150
+ <p v-else-if="!state.references.length" class="desktop-composer__reference-status">{{ t("references.empty") }}</p>
151
+ <div v-else class="desktop-composer__reference-viewport">
152
+ <template v-for="(row, index) in referenceRows" :key="row.id">
153
+ <p v-if="row.section" class="desktop-composer__reference-section">{{ row.section }}</p>
154
+ <button
155
+ type="button"
156
+ class="desktop-composer__reference-item"
157
+ role="option"
158
+ :aria-selected="index === referenceIndex"
159
+ :data-selected="index === referenceIndex"
160
+ @mousedown.prevent
161
+ @mouseenter="referenceIndex = index"
162
+ @click="pickReference(row)"
163
+ >
164
+ <span class="desktop-composer__reference-name">{{ row.name }}</span>
165
+ <span class="desktop-composer__reference-path">{{ row.description }}</span>
166
+ </button>
167
+ </template>
168
+ </div>
169
+ </div>
170
+ <div v-if="state.draftImages.length" class="desktop-composer__attachments">
171
+ <figure
172
+ v-for="image in state.draftImages"
173
+ :key="image.id"
174
+ :data-status="imageStatus(image)"
175
+ >
176
+ <button
177
+ type="button"
178
+ class="desktop-composer__attachment-open"
179
+ :aria-label="t('composer.previewAttachment', { name: image.file.name })"
180
+ :disabled="imageStatus(image) !== 'ready'"
181
+ @click="previewImageId = image.id"
182
+ >
183
+ <img :src="image.previewUrl" :alt="image.file.name" />
184
+ </button>
185
+ <div v-if="imageStatus(image) === 'uploading'" class="desktop-composer__attachment-overlay">
186
+ <svg class="desktop-composer__attachment-spin" viewBox="0 0 16 16" aria-hidden="true">
187
+ <circle cx="8" cy="8" r="6" />
188
+ </svg>
189
+ </div>
190
+ <div v-else-if="imageStatus(image) === 'failed'" class="desktop-composer__attachment-overlay" data-kind="error">
191
+ <span aria-hidden="true">!</span>
192
+ </div>
193
+ <button
194
+ type="button"
195
+ class="desktop-composer__attachment-remove"
196
+ :aria-label="t('composer.removeAttachment', { name: image.file.name })"
197
+ @click="$emit('remove-image', image.id)"
198
+ >
199
+ <CloseIcon />
200
+ </button>
201
+ </figure>
202
+ </div>
203
+ <textarea
204
+ ref="input"
205
+ :value="state.draft"
206
+ class="desktop-composer__input"
207
+ rows="1"
208
+ :placeholder="placeholder"
209
+ :disabled="state.modelBlocked"
210
+ :readonly="state.sending"
211
+ @input="onInput"
212
+ @keydown="onKeydown"
213
+ @wheel="onWheel"
214
+ />
215
+ <div class="desktop-composer__footer">
216
+ <input ref="picker" type="file" multiple class="desktop-composer__picker" @change="onFiles" />
217
+ <LaresPcPopover v-model="commandsButtonOpen" :width="380" placement="top-start">
218
+ <template #trigger="{ toggle }">
219
+ <LaresPcTooltip :label="t('desktop.commands')" placement="top">
220
+ <button
221
+ type="button"
222
+ class="desktop-composer__circle"
223
+ :disabled="attachDisabled"
224
+ aria-haspopup="menu"
225
+ :aria-expanded="commandsButtonOpen"
226
+ :aria-label="t('desktop.commands')"
227
+ @mousedown.prevent
228
+ @click="openCommands(toggle)"
229
+ >
230
+ <PlusIcon />
231
+ </button>
232
+ </LaresPcTooltip>
233
+ </template>
234
+ <div class="desktop-composer__menu">
235
+ <p class="desktop-composer__menu-label">{{ t("desktop.commands") }}</p>
236
+ <p v-if="state.commandsError" class="desktop-composer__menu-status">{{ state.commandsError }}</p>
237
+ <p v-else-if="!state.commands" class="desktop-composer__menu-status">{{ t("agent.loading") }}</p>
238
+ <p v-else-if="visibleCommands.length === 0" class="desktop-composer__menu-status">{{ t("desktop.noCommands") }}</p>
239
+ <button
240
+ v-for="(command, index) in visibleCommands"
241
+ :key="command.name"
242
+ type="button"
243
+ class="desktop-composer__command"
244
+ :data-selected="index === commandIndex"
245
+ @mouseenter="commandIndex = index"
246
+ @click="pickCommand(command)"
247
+ >
248
+ <span class="desktop-composer__command-name">/{{ command.name }}</span>
249
+ <span class="desktop-composer__command-hint">{{ command.description || command.hint }}</span>
250
+ </button>
251
+ </div>
252
+ </LaresPcPopover>
253
+ <LaresPcPopover
254
+ v-if="state.permissionRows.length"
255
+ v-model="permissionOpen"
256
+ class="desktop-composer__permission-slot"
257
+ :width="240"
258
+ placement="top-start"
259
+ >
260
+ <template #trigger="{ toggle }">
261
+ <LaresPcTooltip :label="state.permissionHint" placement="top">
262
+ <button
263
+ type="button"
264
+ class="desktop-composer__permission"
265
+ :disabled="state.permissionBusy"
266
+ :data-open="permissionOpen"
267
+ aria-haspopup="menu"
268
+ :aria-expanded="permissionOpen"
269
+ @mousedown.prevent
270
+ @click="toggle"
271
+ >
272
+ <PermissionIcon :preset="state.permissionCurrent" />
273
+ <span>{{ state.permissionLabel }}</span>
274
+ <ChevronIcon class="desktop-composer__caret" />
275
+ </button>
276
+ </LaresPcTooltip>
277
+ </template>
278
+ <div class="desktop-composer__menu">
279
+ <LaresPcTooltip
280
+ v-for="row in state.permissionRows"
281
+ :key="row.value"
282
+ :label="row.hint"
283
+ placement="right"
284
+ >
285
+ <button
286
+ type="button"
287
+ class="desktop-composer__option"
288
+ :data-selected="row.value === state.permissionCurrent"
289
+ @click="choosePermission(row.value)"
290
+ >
291
+ <span class="desktop-composer__option-main">
292
+ <PermissionIcon :preset="row.value" />
293
+ <span>{{ row.label }}</span>
294
+ </span>
295
+ <CheckIcon v-if="row.value === state.permissionCurrent" />
296
+ </button>
297
+ </LaresPcTooltip>
298
+ </div>
299
+ </LaresPcPopover>
300
+ <LaresPcTooltip v-if="state.planActive" :label="t('plan.disable')" placement="top">
301
+ <button
302
+ type="button"
303
+ class="desktop-composer__plan"
304
+ :disabled="state.sending"
305
+ :aria-label="t('plan.disable')"
306
+ @mousedown.prevent
307
+ @click="$emit('disable-plan')"
308
+ >
309
+ <span>{{ t("plan.active") }}</span>
310
+ <CloseIcon />
311
+ </button>
312
+ </LaresPcTooltip>
313
+ <LaresPcTooltip :label="t('composer.attach')" placement="top">
314
+ <button
315
+ type="button"
316
+ class="desktop-composer__circle"
317
+ :disabled="attachDisabled"
318
+ :aria-label="t('composer.attach')"
319
+ @mousedown.prevent
320
+ @click="$refs.picker?.click()"
321
+ >
322
+ <PaperclipIcon />
323
+ </button>
324
+ </LaresPcTooltip>
325
+ <LaresDesktopContextMeter
326
+ class="desktop-composer__meter"
327
+ :pressure="state.contextPressure"
328
+ :breakdown="state.contextBreakdown"
329
+ :t="t"
330
+ />
331
+ <LaresPcTooltip
332
+ class="desktop-composer__send-slot"
333
+ :label="state.running ? t('chat.stop') : t('chat.send')"
334
+ placement="top"
335
+ >
336
+ <button
337
+ type="button"
338
+ class="desktop-composer__send"
339
+ :disabled="state.running ? state.stopping : !state.canSend || state.sending"
340
+ :aria-label="state.running ? t('chat.stop') : t('chat.send')"
341
+ @mousedown.prevent
342
+ @click="primary"
343
+ >
344
+ <StopIcon v-if="state.running" />
345
+ <ArrowIcon v-else />
346
+ </button>
347
+ </LaresPcTooltip>
348
+ </div>
349
+ </div>
350
+ </Transition>
351
+ <p v-for="item in state.upload.failures" :key="item.id" class="desktop-composer__error">
352
+ {{ t("upload.failed", { name: item.name, reason: t(`error.${item.code}`) }) }}
353
+ </p>
354
+ <LaresPcDialog
355
+ :open="permissionConfirm"
356
+ :title="t('access.confirm.title')"
357
+ :description="t('access.confirm.description')"
358
+ :close-label="t('access.confirm.cancel')"
359
+ :width="460"
360
+ @close="closePermissionConfirm"
361
+ >
362
+ <label class="desktop-composer__risk-check">
363
+ <input v-model="permissionAcknowledged" type="checkbox" />
364
+ <span>{{ t("access.confirm.acknowledge") }}</span>
365
+ </label>
366
+ <template #footer>
367
+ <button type="button" class="desktop-composer__dialog-cancel" @click="closePermissionConfirm">
368
+ {{ t("access.confirm.cancel") }}
369
+ </button>
370
+ <button
371
+ type="button"
372
+ class="desktop-composer__dialog-confirm"
373
+ :disabled="!permissionAcknowledged || state.permissionBusy"
374
+ @click="confirmPermission"
375
+ >
376
+ {{ t("access.confirm.enable") }}
377
+ </button>
378
+ </template>
379
+ </LaresPcDialog>
380
+ <LaresPcDialog
381
+ :open="Boolean(previewImage)"
382
+ :title="previewImage?.file.name ?? ''"
383
+ :close-label="t('desktop.close')"
384
+ :width="720"
385
+ @close="previewImageId = ''"
386
+ >
387
+ <img
388
+ v-if="previewImage"
389
+ class="desktop-composer__preview"
390
+ :src="previewImage.previewUrl"
391
+ :alt="previewImage.file.name"
392
+ />
393
+ </LaresPcDialog>
394
+ </form>
395
+ </template>
396
+
397
+ <script>
398
+ import { h } from "vue";
399
+ import { resolveSubmitMode } from "@olares/lares-core/larepass/submission-settings";
400
+ import { activeReferenceToken, insertReference } from "@olares/lares-core/larepass/references";
401
+ import { draftImageStatus } from "@olares/lares-core/files/draft-images";
402
+ import LaresDesktopQueue from "./DesktopQueue.vue";
403
+ import LaresDesktopQuestions from "./DesktopQuestions.vue";
404
+ import LaresDesktopContextMeter from "./DesktopContextMeter.vue";
405
+ import LaresDesktopGoal from "./DesktopGoal.vue";
406
+ import LaresDesktopTodos from "./DesktopTodos.vue";
407
+ import LaresPcDialog from "./ui/PcDialog.vue";
408
+ import LaresPcPopover from "./ui/PcPopover.vue";
409
+ import LaresPcTooltip from "./ui/PcTooltip.vue";
410
+
411
+ const icon = (name, ...paths) => ({
412
+ name,
413
+ render() {
414
+ return h("svg", { viewBox: "0 0 24 24", "aria-hidden": "true" }, paths.map((d) => h("path", { d })));
415
+ },
416
+ });
417
+
418
+ const ChevronIcon = icon("ChevronIcon", "m8 10 4 4 4-4");
419
+ const CheckIcon = icon("CheckIcon", "m5 12.5 4 4 10-10");
420
+ const PlusIcon = icon("PlusIcon", "M12 5v14M5 12h14");
421
+ const PaperclipIcon = icon("PaperclipIcon", "m9.5 12.5 5.8-5.8a3 3 0 0 1 4.2 4.2l-8.1 8.1a5 5 0 0 1-7.1-7.1l8-8");
422
+ const ArrowIcon = icon("ArrowIcon", "M12 19V7M6.5 12.5 12 7l5.5 5.5");
423
+ const StopIcon = {
424
+ name: "StopIcon",
425
+ render() {
426
+ return h("svg", { viewBox: "0 0 24 24", "aria-hidden": "true" }, [
427
+ h("rect", { x: 7, y: 7, width: 10, height: 10, rx: 2.5, fill: "currentColor", stroke: "none" }),
428
+ ]);
429
+ },
430
+ };
431
+ const CloseIcon = icon("CloseIcon", "m7 7 10 10M17 7 7 17");
432
+
433
+ const SHIELD = "M12 3.5 19 6v5.4c0 4.2-2.8 7.4-7 9.1-4.2-1.7-7-4.9-7-9.1V6l7-2.5Z";
434
+ /** One shield, three glyphs: what this preset lets a tool do. */
435
+ const PRESET_GLYPHS = {
436
+ "read-only": "m9.2 12 1.8 1.8 3.8-4",
437
+ "workspace-write": "m9.6 13.9 4.3-4.3 1.4 1.4-4.3 4.3H9.6z",
438
+ "danger-full-access": "M12 8.4v3.8M12 14.8v.1",
439
+ };
440
+
441
+ const PermissionIcon = {
442
+ name: "PermissionIcon",
443
+ props: { preset: { type: String, default: "" } },
444
+ render() {
445
+ const glyph = PRESET_GLYPHS[this.preset] ?? PRESET_GLYPHS["workspace-write"];
446
+ return h("svg", { viewBox: "0 0 24 24", "aria-hidden": "true" }, [
447
+ h("path", { d: SHIELD }),
448
+ h("path", { d: glyph }),
449
+ ]);
450
+ },
451
+ };
452
+
453
+ export default {
454
+ name: "LaresDesktopComposer",
455
+ components: { LaresDesktopContextMeter, LaresDesktopGoal, LaresDesktopQuestions, LaresDesktopQueue, LaresDesktopTodos, LaresPcDialog, LaresPcPopover, LaresPcTooltip, ChevronIcon, CheckIcon, PlusIcon, PaperclipIcon, ArrowIcon, StopIcon, CloseIcon, PermissionIcon },
456
+ props: {
457
+ state: { type: Object, required: true },
458
+ t: { type: Function, required: true },
459
+ modelKey: { type: Function, required: true },
460
+ effortName: { type: Function, required: true },
461
+ },
462
+ emits: [
463
+ "update-draft", "send", "stop", "steer-queue", "files", "choose-model", "choose-effort", "answer-approval", "answer-questions",
464
+ "choose-permission", "disable-plan", "goal-action", "open-commands", "pick-command", "queue-action", "remove-image",
465
+ ],
466
+ data() {
467
+ return {
468
+ modelOpen: false,
469
+ effortOpen: false,
470
+ permissionOpen: false,
471
+ permissionConfirm: false,
472
+ permissionAcknowledged: false,
473
+ commandsOpen: false,
474
+ commandsButtonOpen: false,
475
+ commandTyped: false,
476
+ commandIndex: 0,
477
+ dropping: false,
478
+ dropDepth: 0,
479
+ referenceOpen: false,
480
+ referenceToken: null,
481
+ referenceIndex: 0,
482
+ referenceTimer: 0,
483
+ previewImageId: "",
484
+ };
485
+ },
486
+ computed: {
487
+ /** Held by id, so removing or sending the draft closes the dialog. */
488
+ previewImage() {
489
+ return this.state.draftImages.find((row) => row.id === this.previewImageId) ?? null;
490
+ },
491
+ /** Row chrome follows dsh's `@` menu: "<kind> · <name>" plus a section break. */
492
+ referenceRows() {
493
+ let previous = "";
494
+ return this.state.references.map((row) => {
495
+ const group = row.kind === "session" ? "sessions" : "files";
496
+ const section = group === previous ? "" : this.t(`references.section.${group}`);
497
+ previous = group;
498
+ return { ...row, section, name: `${this.t(`references.${row.kind}`)} · ${row.label}` };
499
+ });
500
+ },
501
+ attachDisabled() {
502
+ return !this.state.sessionId || this.state.historyLoading || this.state.starting;
503
+ },
504
+ visibleCommands() {
505
+ const rows = this.state.commands || [];
506
+ if (!this.commandTyped) return rows;
507
+ const needle = this.state.draft.match(/^\/([^\s]*)$/)?.[1]?.toLowerCase() ?? "";
508
+ return rows.filter((row) => row.name.toLowerCase().includes(needle));
509
+ },
510
+ composerOverlayPointerActive() {
511
+ return this.referenceOpen || (this.commandsOpen && this.commandTyped);
512
+ },
513
+ placeholder() {
514
+ if (this.state.modelBlocked) return this.t("chat.placeholderNoModel");
515
+ if (
516
+ this.state.running
517
+ && !this.state.draft.trim()
518
+ && !this.state.draftImages.length
519
+ && this.state.queue.some((row) => row.placement === "queued")
520
+ ) {
521
+ const shortcut = /Mac|iPhone|iPad/.test(globalThis.navigator?.platform || "")
522
+ ? "⌘↵"
523
+ : "Ctrl+Enter";
524
+ return this.t("chat.placeholderSteerQueue", { shortcut });
525
+ }
526
+ if (this.state.planActive) return this.t("chat.placeholderPlan");
527
+ return this.t("chat.placeholderDesktop");
528
+ },
529
+ },
530
+ watch: {
531
+ "state.draft"() {
532
+ this.autosize();
533
+ },
534
+ composerOverlayPointerActive(active) {
535
+ if (active) window.addEventListener("mousedown", this.onComposerOverlayPointer, true);
536
+ else window.removeEventListener("mousedown", this.onComposerOverlayPointer, true);
537
+ },
538
+ },
539
+ mounted() {
540
+ this.autosize();
541
+ },
542
+ beforeUnmount() {
543
+ window.clearTimeout(this.referenceTimer);
544
+ window.removeEventListener("mousedown", this.onComposerOverlayPointer, true);
545
+ },
546
+ methods: {
547
+ chooseModel(model) {
548
+ this.modelOpen = false;
549
+ this.$emit("choose-model", model);
550
+ },
551
+ chooseEffort(id) {
552
+ this.effortOpen = false;
553
+ this.$emit("choose-effort", id);
554
+ },
555
+ choosePermission(value) {
556
+ this.permissionOpen = false;
557
+ if (value === this.state.permissionCurrent) return;
558
+ if (value === "danger-full-access") {
559
+ this.permissionAcknowledged = false;
560
+ this.permissionConfirm = true;
561
+ return;
562
+ }
563
+ this.$emit("choose-permission", value);
564
+ },
565
+ closePermissionConfirm() {
566
+ this.permissionConfirm = false;
567
+ this.permissionAcknowledged = false;
568
+ },
569
+ confirmPermission() {
570
+ if (!this.permissionAcknowledged || this.state.permissionBusy) return;
571
+ this.closePermissionConfirm();
572
+ this.$emit("choose-permission", "danger-full-access");
573
+ },
574
+ /** The registry is read when the menu opens, not on every keystroke. */
575
+ openCommands(toggle) {
576
+ const opening = !this.commandsButtonOpen;
577
+ this.commandTyped = false;
578
+ this.commandsOpen = false;
579
+ this.commandIndex = 0;
580
+ toggle();
581
+ if (opening) this.$emit("open-commands");
582
+ },
583
+ pickCommand(command) {
584
+ this.commandsOpen = false;
585
+ this.commandsButtonOpen = false;
586
+ this.commandTyped = false;
587
+ this.$emit("pick-command", command);
588
+ },
589
+ onInput(event) {
590
+ const value = event.target.value;
591
+ this.$emit("update-draft", value);
592
+ this.trackReference(value, event.target.selectionStart ?? value.length);
593
+ const typed = /^\/[^\s]*$/.test(value);
594
+ if (typed) {
595
+ if (!this.commandsOpen) this.$emit("open-commands");
596
+ this.commandsButtonOpen = false;
597
+ this.commandTyped = true;
598
+ this.commandIndex = 0;
599
+ this.commandsOpen = true;
600
+ } else if (this.commandTyped) {
601
+ this.commandTyped = false;
602
+ this.commandsOpen = false;
603
+ }
604
+ },
605
+ closeReferences() {
606
+ window.clearTimeout(this.referenceTimer);
607
+ this.referenceOpen = false;
608
+ this.referenceToken = null;
609
+ },
610
+ /**
611
+ * A press outside dismisses the panel; one inside the textarea only moves
612
+ * the caret, which decides on its own whether a mention is still under it.
613
+ */
614
+ onComposerOverlayPointer(event) {
615
+ if (this.$refs.referenceMenu?.contains(event.target)) return;
616
+ if (this.$refs.commandsMenu?.contains(event.target)) return;
617
+ const input = this.$refs.input;
618
+ if (event.target !== input) {
619
+ this.closeReferences();
620
+ if (this.commandTyped) {
621
+ this.commandsOpen = false;
622
+ this.commandTyped = false;
623
+ }
624
+ return;
625
+ }
626
+ window.setTimeout(() => {
627
+ if (this.referenceOpen) this.trackReference(this.state.draft, input.selectionStart ?? 0);
628
+ }, 0);
629
+ },
630
+ trackReference(value, caret) {
631
+ const token = activeReferenceToken(value, caret);
632
+ window.clearTimeout(this.referenceTimer);
633
+ if (!token) {
634
+ this.closeReferences();
635
+ return;
636
+ }
637
+ this.commandsOpen = false;
638
+ this.commandsButtonOpen = false;
639
+ this.commandTyped = false;
640
+ this.referenceOpen = true;
641
+ this.referenceToken = token;
642
+ this.referenceIndex = 0;
643
+ this.referenceTimer = window.setTimeout(() => {
644
+ this.$emit("open-references", { query: token.query, quoted: token.quoted });
645
+ }, 100);
646
+ },
647
+ pickReference(row) {
648
+ if (!this.referenceToken) return;
649
+ const inserted = insertReference(this.state.draft, this.referenceToken, row);
650
+ this.$emit("update-draft", inserted.draft);
651
+ this.$nextTick(() => {
652
+ const input = this.$refs.input;
653
+ input?.focus();
654
+ input?.setSelectionRange(inserted.caret, inserted.caret);
655
+ if (row.continuation) this.trackReference(inserted.draft, inserted.caret);
656
+ else this.closeReferences();
657
+ });
658
+ },
659
+ focus() {
660
+ this.$refs.input?.focus();
661
+ },
662
+ imageStatus(image) {
663
+ return draftImageStatus(image);
664
+ },
665
+ onFiles(event) {
666
+ const files = Array.from(event.currentTarget.files ?? []);
667
+ event.currentTarget.value = "";
668
+ if (files.length) this.$emit("files", files);
669
+ },
670
+ onPaste(event) {
671
+ const files = Array.from(event.clipboardData?.files ?? []);
672
+ if (!files.length) return;
673
+ event.preventDefault();
674
+ this.$emit("files", files);
675
+ },
676
+ onDrop(event) {
677
+ const files = Array.from(event.dataTransfer?.files ?? []);
678
+ if (files.length) this.$emit("files", files);
679
+ },
680
+ onKeydown(event) {
681
+ if (this.referenceOpen) {
682
+ if (event.key === "Escape") {
683
+ event.preventDefault();
684
+ this.closeReferences();
685
+ return;
686
+ }
687
+ if ((event.key === "ArrowDown" || event.key === "ArrowUp") && this.state.references.length) {
688
+ event.preventDefault();
689
+ const step = event.key === "ArrowDown" ? 1 : -1;
690
+ this.referenceIndex = (
691
+ this.referenceIndex + step + this.state.references.length
692
+ ) % this.state.references.length;
693
+ return;
694
+ }
695
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing && this.state.references.length) {
696
+ event.preventDefault();
697
+ this.pickReference(this.state.references[this.referenceIndex] ?? this.state.references[0]);
698
+ return;
699
+ }
700
+ }
701
+ if (this.commandsOpen || this.commandsButtonOpen) {
702
+ if (event.key === "Escape") {
703
+ event.preventDefault();
704
+ this.commandsOpen = false;
705
+ this.commandsButtonOpen = false;
706
+ this.commandTyped = false;
707
+ return;
708
+ }
709
+ if ((event.key === "ArrowDown" || event.key === "ArrowUp") && this.visibleCommands.length) {
710
+ event.preventDefault();
711
+ const step = event.key === "ArrowDown" ? 1 : -1;
712
+ this.commandIndex = (this.commandIndex + step + this.visibleCommands.length) % this.visibleCommands.length;
713
+ return;
714
+ }
715
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing && this.visibleCommands.length) {
716
+ event.preventDefault();
717
+ this.pickCommand(this.visibleCommands[this.commandIndex] ?? this.visibleCommands[0]);
718
+ return;
719
+ }
720
+ }
721
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
722
+ event.preventDefault();
723
+ const accelerated = event.metaKey || event.ctrlKey;
724
+ if (accelerated && this.state.running && !this.state.canSend) {
725
+ if (this.state.queue.some((row) => row.placement === "queued")) this.$emit("steer-queue");
726
+ return;
727
+ }
728
+ if (this.state.canSend) {
729
+ this.$emit("send", resolveSubmitMode(
730
+ this.state.running,
731
+ accelerated,
732
+ true,
733
+ this.state.busyEnter,
734
+ ));
735
+ }
736
+ },
737
+ onWheel(event) {
738
+ const input = event.currentTarget;
739
+ const atTop = input.scrollTop <= 0 && event.deltaY < 0;
740
+ const atBottom = input.scrollTop + input.clientHeight >= input.scrollHeight - 1 && event.deltaY > 0;
741
+ if (!atTop && !atBottom) return;
742
+ const log = document.querySelector(".lares-log");
743
+ if (!log) return;
744
+ log.scrollTop += event.deltaY;
745
+ event.preventDefault();
746
+ },
747
+ primary() {
748
+ this.$emit(this.state.running ? "stop" : "send");
749
+ },
750
+ autosize() {
751
+ this.$nextTick(() => {
752
+ const input = this.$refs.input;
753
+ if (!input) return;
754
+ input.style.height = "auto";
755
+ input.style.height = `${Math.min(input.scrollHeight, 200)}px`;
756
+ });
757
+ },
758
+ },
759
+ };
760
+ </script>
761
+
762
+ <style scoped>
763
+ .desktop-composer {
764
+ position: relative;
765
+ z-index: 2;
766
+ width: calc(var(--lares-column, min(760px, 100%)) - 2 * var(--lares-gutter, 24px));
767
+ margin: 0 auto;
768
+ padding: 8px 0 20px;
769
+ }
770
+ .desktop-composer__model-row { display:flex; justify-content:flex-end; gap:2px; min-height:28px; margin-bottom:6px; }
771
+ .desktop-composer__model,
772
+ .desktop-composer__circle,
773
+ .desktop-composer__send {
774
+ border:0; color:var(--q-ink-2); cursor:pointer;
775
+ }
776
+ .desktop-composer__model { display:flex; align-items:center; gap:4px; border-radius:999px; padding:4px 10px; background:transparent; font-size:14px; }
777
+ .desktop-composer__model svg,
778
+ .desktop-composer__permission svg,
779
+ .desktop-composer__circle svg,
780
+ .desktop-composer__send svg,
781
+ .desktop-composer__option svg { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; }
782
+ .desktop-composer__card { position:relative; border:1px solid var(--q-input-stroke); border-radius:14px; padding:10px 12px 9px; background:var(--q-background-2, var(--q-background-1)); box-shadow:0 1px 3px rgb(0 0 0 / 8%); }
783
+ .desktop-composer__card[data-dropping="true"] { border-color:var(--q-blue-default); background:color-mix(in srgb, var(--q-blue-default) 8%, var(--q-background-2, var(--q-background-1))); }
784
+ /* Metrics mirror dsh's `@` menu (MenuView.module.css); only the palette is the
785
+ LarePass shell's, since dsh's --dsw-* tokens do not exist here. */
786
+ .desktop-composer__reference-menu { position:absolute; z-index:20; bottom:calc(100% + 4px); left:0; display:flex; min-width:min(260px, 100%); max-width:min(537px, 100%); max-height:320px; flex-direction:column; overflow:hidden; border:1px solid var(--q-separator); border-radius:12px; padding:4px; background:var(--q-background-1); box-shadow:0 12px 30px rgb(0 0 0 / 18%); }
787
+ .desktop-composer__reference-viewport { display:flex; min-height:0; flex-direction:column; overflow-y:auto; }
788
+ .desktop-composer__reference-status { display:flex; min-height:40px; align-items:center; margin:0; padding:8px 10px; color:var(--q-ink-3); font-size:14px; line-height:22px; }
789
+ .desktop-composer__reference-section { min-height:26px; flex:none; margin:0; padding:6px 10px 2px; color:var(--q-ink-3); font-size:12px; font-weight:500; line-height:18px; }
790
+ .desktop-composer__reference-section:not(:first-child) { margin-top:4px; }
791
+ .desktop-composer__reference-item { display:flex; width:100%; min-height:40px; align-items:center; gap:8px; border:0; border-radius:10px; padding:8px 10px; background:transparent; color:var(--q-ink-1); font:inherit; font-size:14px; line-height:22px; text-align:left; cursor:pointer; }
792
+ .desktop-composer__reference-item:hover,
793
+ .desktop-composer__reference-item[data-selected="true"] { background:var(--q-background-hover); }
794
+ .desktop-composer__reference-name { max-width:40%; flex:none; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
795
+ .desktop-composer__reference-path { min-width:0; flex:1; overflow:hidden; color:var(--q-ink-3); text-overflow:ellipsis; white-space:nowrap; }
796
+ .desktop-composer__approval { overflow:hidden; border:1px solid var(--q-separator); border-radius:14px; background:var(--q-background-2); box-shadow:0 1px 3px rgb(0 0 0 / 8%); }
797
+ .desktop-composer__approval-strip { display:flex; height:30px; align-items:center; gap:7px; padding:0 12px; background:var(--q-background-3); color:var(--q-ink-3); font-size:12px; }
798
+ .desktop-composer__approval-strip span { width:6px; height:6px; border-radius:50%; background:var(--q-orange-default); }
799
+ .desktop-composer__approval-body { max-height:96px; overflow:auto; padding:14px 16px; color:var(--q-ink-1); font-size:14px; line-height:20px; }
800
+ .desktop-composer__approval-actions { display:flex; justify-content:flex-end; gap:8px; padding:8px 12px 12px; }
801
+ .desktop-composer__approval-actions button { height:32px; border:1px solid var(--q-separator); border-radius:7px; padding:0 13px; background:transparent; color:var(--q-ink-2); font:inherit; cursor:pointer; }
802
+ .desktop-composer__approval-actions button:last-child { border-color:transparent; background:var(--q-blue-default); color:var(--q-ink-on-brand); }
803
+ .desktop-composer__approval-actions button:disabled { opacity:.45; cursor:default; }
804
+ .desktop-composer__attachments { display:flex; gap:8px; overflow-x:auto; padding:0 0 8px 2px; }
805
+ .desktop-composer__attachments figure { position:relative; width:64px; height:64px; flex:0 0 64px; overflow:hidden; margin:0; border:1px solid var(--q-separator); border-radius:9px; background:var(--q-background-3); }
806
+ .desktop-composer__attachments img { width:100%; height:100%; object-fit:cover; transition:opacity .22s ease; }
807
+ .desktop-composer__attachments figure[data-status="failed"] { border-color:var(--q-orange-default); }
808
+ .desktop-composer__attachment-overlay { position:absolute; inset:0; display:grid; place-items:center; background:rgb(0 0 0 / 42%); color:white; pointer-events:none; }
809
+ .desktop-composer__attachment-overlay[data-kind="error"] { font-size:16px; font-weight:700; }
810
+ .desktop-composer__attachment-spin { width:18px; height:18px; animation:desktop-attachment-spin .8s linear infinite; }
811
+ .desktop-composer__attachment-spin circle { fill:none; stroke:currentColor; stroke-dasharray:24 12; }
812
+ @keyframes desktop-attachment-spin { to { transform:rotate(360deg); } }
813
+ @media (prefers-reduced-motion: reduce) { .desktop-composer__attachment-spin { animation:none; } }
814
+ .desktop-composer__attachment-open { display:block; width:100%; height:100%; border:0; padding:0; background:transparent; cursor:zoom-in; }
815
+ .desktop-composer__attachment-remove { position:absolute; top:3px; right:3px; display:grid; width:20px; height:20px; place-items:center; border:0; border-radius:50%; padding:0; background:rgb(0 0 0 / 58%); color:white; cursor:pointer; }
816
+ .desktop-composer__attachment-remove svg { width:12px; height:12px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; }
817
+ .desktop-composer__preview { display:block; max-width:100%; max-height:calc(100vh - 220px); margin:0 auto; border-radius:8px; }
818
+ .desktop-composer__input { box-sizing:border-box; width:100%; min-height:37px; max-height:200px; resize:none; overflow-y:auto; border:0; padding:1px 2px 8px; outline:0; background:transparent; color:var(--q-ink-1); font:inherit; font-size:16px; line-height:1.5; }
819
+ .desktop-composer__footer { display:flex; align-items:center; gap:8px; min-height:30px; }
820
+ /* The flex items here are the tooltip/popover wrappers, not the buttons. They
821
+ carry min-width:0, so without this the fixed 28px controls get squeezed
822
+ narrower than their own 16px glyph and the icons spill onto their neighbours.
823
+ Only the permission label gives way, by ellipsis. */
824
+ .desktop-composer__footer > * { flex-shrink:0; }
825
+ .desktop-composer__permission-slot { min-width:0; flex-shrink:1; }
826
+ .desktop-composer__permission { min-width:0; }
827
+ .desktop-composer__permission svg { flex-shrink:0; }
828
+ .desktop-composer__permission span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
829
+ .desktop-composer__picker { display:none; }
830
+ .desktop-composer__circle { display:grid; width:28px; height:28px; place-items:center; border-radius:50%; background:var(--q-background-3); }
831
+ .desktop-composer__permission { display:flex; align-items:center; gap:5px; height:28px; border:0; border-radius:999px; padding:0 8px; background:var(--q-background-3); color:var(--q-ink-2); font:inherit; font-size:14px; cursor:pointer; }
832
+ .desktop-composer__plan { display:flex; height:28px; align-items:center; gap:4px; border:0; border-radius:999px; padding:0 8px; background:var(--q-background-3); color:var(--q-orange-default); font:inherit; font-size:13px; cursor:pointer; }
833
+ .desktop-composer__plan svg { width:14px; height:14px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; }
834
+ .desktop-composer__permission .desktop-composer__caret { transition:transform .15s ease; }
835
+ .desktop-composer__permission[data-open="true"] .desktop-composer__caret { transform:rotate(180deg); }
836
+ .desktop-composer__model:hover,
837
+ .desktop-composer__circle:hover,
838
+ .desktop-composer__permission:hover,
839
+ .desktop-composer__plan:hover { background:var(--q-background-hover); }
840
+ /* The meter rides with the send button; it renders nothing without an occupancy,
841
+ in which case the send button keeps the auto margin itself. */
842
+ .desktop-composer__meter { margin-left:auto; }
843
+ .desktop-composer__send-slot { margin-left:auto; }
844
+ .desktop-composer__meter + .desktop-composer__send-slot { margin-left:0; }
845
+ .desktop-composer__send { display:grid; width:28px; height:28px; place-items:center; border-radius:50%; background:var(--q-blue-default); color:var(--q-ink-on-brand); }
846
+ .desktop-composer button:disabled { opacity:.45; cursor:default; }
847
+ .desktop-composer__menu { margin:-2px; }
848
+ .desktop-composer__menu-label,
849
+ .desktop-composer__menu-status { margin:0; padding:7px 8px 4px; color:var(--q-ink-3); font-size:13px; }
850
+ .desktop-composer__option { display:flex; align-items:center; justify-content:space-between; width:100%; border:0; border-radius:6px; padding:7px 8px; background:transparent; color:var(--q-ink-2); text-align:left; cursor:pointer; }
851
+ /* Menu rows are wrapped in a tooltip; the wrapper must fill the panel. Footer
852
+ tooltips are flex items and must keep their own width. */
853
+ .desktop-composer__menu :deep(.lares-pc-tooltip) { width:100%; }
854
+ .desktop-composer__option:hover { background:var(--q-background-hover); }
855
+ .desktop-composer__option[data-selected="true"] { color:var(--q-blue-default); }
856
+ .desktop-composer__option-main { display:flex; min-width:0; align-items:center; gap:8px; }
857
+ .desktop-composer__option-main span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
858
+ .desktop-composer__command { display:flex; align-items:baseline; gap:10px; width:100%; border:0; border-radius:6px; padding:7px 8px; background:transparent; color:var(--q-ink-2); font:inherit; text-align:left; cursor:pointer; }
859
+ .desktop-composer__command:hover { background:var(--q-background-hover); }
860
+ .desktop-composer__command[data-selected="true"] { background:var(--q-background-hover); }
861
+ .desktop-composer__command-name { flex-shrink:0; color:var(--q-ink-1); font-size:15px; }
862
+ .desktop-composer__command-hint { min-width:0; overflow:hidden; color:var(--q-ink-3); font-size:14px; text-overflow:ellipsis; white-space:nowrap; }
863
+ .desktop-composer__error { margin:5px 4px 0; color:var(--q-orange-default); font-size:14px; }
864
+ .desktop-composer__risk-check { display:flex; align-items:flex-start; gap:10px; color:var(--q-ink-2); font-size:14px; line-height:20px; cursor:pointer; }
865
+ .desktop-composer__risk-check input { width:16px; height:16px; margin:2px 0 0; accent-color:var(--q-blue-default); }
866
+ .desktop-composer__dialog-cancel,
867
+ .desktop-composer__dialog-confirm { min-width:76px; height:32px; border-radius:7px; padding:0 12px; font:inherit; cursor:pointer; }
868
+ .desktop-composer__dialog-cancel { border:1px solid var(--q-separator); background:transparent; color:var(--q-ink-2); }
869
+ .desktop-composer__dialog-confirm { border:0; background:var(--q-blue-default); color:var(--q-ink-on-brand); }
870
+ .desktop-composer__dialog-confirm:disabled { opacity:.45; cursor:default; }
871
+ </style>