@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,81 @@
1
+ <template>
2
+ <LaresPcPopover v-if="occupancy" v-model="open" :width="260" placement="top-end">
3
+ <template #trigger="{ toggle }">
4
+ <LaresPcTooltip :label="t('context.used', occupancy)" placement="top">
5
+ <button
6
+ type="button"
7
+ class="desktop-context"
8
+ :aria-label="t('context.used', occupancy)"
9
+ @click="toggle"
10
+ >
11
+ <svg viewBox="0 0 20 20" aria-hidden="true">
12
+ <circle cx="10" cy="10" r="7" class="desktop-context__track" />
13
+ <circle
14
+ cx="10"
15
+ cy="10"
16
+ r="7"
17
+ class="desktop-context__value"
18
+ pathLength="100"
19
+ :stroke-dasharray="`${occupancy.percent} 100`"
20
+ />
21
+ </svg>
22
+ </button>
23
+ </LaresPcTooltip>
24
+ </template>
25
+ <div class="desktop-context__panel">
26
+ <strong>{{ t("context.title") }}</strong>
27
+ <span>{{ t("context.used", occupancy) }}</span>
28
+ <div class="desktop-context__bar"><i :style="{ width: `${occupancy.percent}%` }" /></div>
29
+ <dl v-if="breakdown">
30
+ <template v-for="row in rows" :key="row.key">
31
+ <dt>{{ t(`context.${row.key}`) }}</dt>
32
+ <dd>~{{ row.compact }}</dd>
33
+ </template>
34
+ </dl>
35
+ </div>
36
+ </LaresPcPopover>
37
+ </template>
38
+
39
+ <script>
40
+ import { occupancyBreakdown, occupancyFromPressure } from "@olares/lares-core/larepass/context-occupancy";
41
+ import LaresPcPopover from "./ui/PcPopover.vue";
42
+ import LaresPcTooltip from "./ui/PcTooltip.vue";
43
+
44
+ export default {
45
+ name: "LaresDesktopContextMeter",
46
+ components: { LaresPcPopover, LaresPcTooltip },
47
+ props: {
48
+ pressure: { type: Object, default: null },
49
+ breakdown: { type: Object, default: null },
50
+ t: { type: Function, required: true },
51
+ },
52
+ data() {
53
+ return { open: false };
54
+ },
55
+ computed: {
56
+ occupancy() {
57
+ return occupancyFromPressure(this.pressure);
58
+ },
59
+ rows() {
60
+ return occupancyBreakdown(this.breakdown);
61
+ },
62
+ },
63
+ };
64
+ </script>
65
+
66
+ <style scoped>
67
+ .desktop-context { display:grid; width:28px; height:28px; place-items:center; border:0; border-radius:50%; padding:0; background:transparent; color:var(--q-ink-3); cursor:pointer; }
68
+ .desktop-context:hover { background:var(--q-background-hover); color:var(--q-ink-2); }
69
+ .desktop-context svg { width:18px; height:18px; transform:rotate(-90deg); fill:none; }
70
+ .desktop-context circle { stroke-width:2.5; }
71
+ .desktop-context__track { stroke:var(--q-separator); }
72
+ .desktop-context__value { stroke:var(--q-blue-default); stroke-linecap:round; }
73
+ .desktop-context__panel { display:flex; flex-direction:column; gap:8px; padding:4px; color:var(--q-ink-2); font-size:12px; }
74
+ .desktop-context__panel strong { color:var(--q-ink-1); font-size:14px; }
75
+ .desktop-context__bar { height:6px; overflow:hidden; border-radius:3px; background:var(--q-background-3); }
76
+ .desktop-context__bar i { display:block; height:100%; border-radius:inherit; background:var(--q-blue-default); }
77
+ .desktop-context__panel dl { display:grid; grid-template-columns:1fr auto; gap:5px 12px; margin:3px 0 0; }
78
+ .desktop-context__panel dt,
79
+ .desktop-context__panel dd { margin:0; }
80
+ .desktop-context__panel dd { color:var(--q-ink-3); }
81
+ </style>
@@ -0,0 +1,88 @@
1
+ <template>
2
+ <Transition name="lares-panel">
3
+ <div v-if="goal" class="desktop-goal">
4
+ <div class="desktop-goal__body">
5
+ <input
6
+ v-if="editing"
7
+ ref="editor"
8
+ v-model="objective"
9
+ :aria-label="t('goal.edit')"
10
+ @keydown.enter.exact.prevent="save"
11
+ @keydown.esc="editing = false"
12
+ />
13
+ <template v-else>
14
+ <span class="desktop-goal__mark" aria-hidden="true">◎</span>
15
+ <span class="desktop-goal__text">{{ goal.objective }}</span>
16
+ <span class="desktop-goal__round">{{ goal.roundsStarted || 0 }}/{{ goal.maxGoalRounds }}</span>
17
+ </template>
18
+ </div>
19
+ <div class="desktop-goal__actions">
20
+ <button v-if="editing" type="button" :disabled="busy || !objective.trim()" @click="save">✓</button>
21
+ <button v-if="editing" type="button" :disabled="busy" @click="editing = false">×</button>
22
+ <template v-else>
23
+ <button type="button" :aria-label="t('goal.edit')" :disabled="busy" @click="beginEdit">✎</button>
24
+ <button
25
+ v-if="goal.phase === 'active'"
26
+ type="button"
27
+ :aria-label="t('goal.pause')"
28
+ :disabled="busy"
29
+ @click="$emit('action', 'pause')"
30
+ >Ⅱ</button>
31
+ <button
32
+ v-else-if="goal.phase === 'paused'"
33
+ type="button"
34
+ :aria-label="t('goal.resume')"
35
+ :disabled="busy"
36
+ @click="$emit('action', 'resume')"
37
+ >▶</button>
38
+ <button type="button" :aria-label="t('goal.clear')" :disabled="busy" @click="$emit('action', 'clear')">×</button>
39
+ </template>
40
+ </div>
41
+ </div>
42
+ </Transition>
43
+ </template>
44
+
45
+ <script>
46
+ export default {
47
+ name: "LaresDesktopGoal",
48
+ props: {
49
+ projection: { type: Object, default: null },
50
+ busy: { type: Boolean, default: false },
51
+ t: { type: Function, required: true },
52
+ },
53
+ emits: ["action"],
54
+ data() {
55
+ return { editing: false, objective: "" };
56
+ },
57
+ computed: {
58
+ goal() {
59
+ return this.projection?.goal ?? null;
60
+ },
61
+ },
62
+ methods: {
63
+ beginEdit() {
64
+ this.objective = this.goal?.objective ?? "";
65
+ this.editing = true;
66
+ this.$nextTick(() => this.$refs.editor?.focus?.());
67
+ },
68
+ save() {
69
+ if (!this.objective.trim()) return;
70
+ this.$emit("action", "edit", this.objective.trim());
71
+ this.editing = false;
72
+ },
73
+ },
74
+ };
75
+ </script>
76
+
77
+ <style scoped>
78
+ .desktop-goal { display:flex; box-sizing:border-box; width:calc(100% - 24px); min-height:40px; align-items:center; gap:8px; margin:0 auto -3px; border:1px solid var(--q-separator); border-bottom:0; border-radius:12px 12px 0 0; padding:5px 8px 7px 12px; background:var(--q-background-3); }
79
+ .desktop-goal__body { display:flex; min-width:0; flex:1; align-items:center; gap:8px; }
80
+ .desktop-goal__mark { color:var(--q-blue-default); }
81
+ .desktop-goal__text { overflow:hidden; flex:1; color:var(--q-ink-2); font-size:13px; text-overflow:ellipsis; white-space:nowrap; }
82
+ .desktop-goal__round { color:var(--q-ink-3); font-size:11px; }
83
+ .desktop-goal input { box-sizing:border-box; width:100%; height:28px; border:1px solid var(--q-input-stroke); border-radius:6px; padding:0 8px; outline:none; background:var(--q-background-1); color:var(--q-ink-1); }
84
+ .desktop-goal__actions { display:flex; gap:3px; }
85
+ .desktop-goal button { display:grid; width:27px; height:27px; place-items:center; border:0; border-radius:50%; background:transparent; color:var(--q-ink-3); cursor:pointer; }
86
+ .desktop-goal button:hover:not(:disabled) { background:var(--q-background-hover); color:var(--q-ink-2); }
87
+ .desktop-goal button:disabled { opacity:.45; cursor:default; }
88
+ </style>
@@ -0,0 +1,58 @@
1
+ <template>
2
+ <section class="desktop-preview" :aria-label="t('preview')">
3
+ <header class="desktop-preview__bar">
4
+ <span class="desktop-preview__path" :title="path">{{ path }}</span>
5
+ <a
6
+ v-if="downloadHref"
7
+ class="desktop-preview__download"
8
+ :href="downloadHref"
9
+ download
10
+ >{{ t("download") }}</a>
11
+ </header>
12
+ <div class="desktop-preview__content">
13
+ <LaresPreviewBody
14
+ :path="path"
15
+ :session-id="sessionId"
16
+ :status="status"
17
+ :data="data"
18
+ :error="error"
19
+ :media-src="mediaSrc"
20
+ :href-for="hrefFor"
21
+ :t="t"
22
+ @retry="$emit('retry')"
23
+ @open="$emit('open', $event)"
24
+ />
25
+ </div>
26
+ </section>
27
+ </template>
28
+
29
+ <script>
30
+ import LaresPreviewBody from "../preview/PreviewBody.vue";
31
+
32
+ export default {
33
+ name: "LaresDesktopPreview",
34
+ components: { LaresPreviewBody },
35
+ props: {
36
+ path: { type: String, required: true },
37
+ sessionId: { type: String, default: "" },
38
+ status: { type: String, default: "loading" },
39
+ data: { type: Object, default: null },
40
+ error: { type: String, default: "" },
41
+ mediaSrc: { type: String, default: "" },
42
+ downloadHref: { type: String, default: "" },
43
+ hrefFor: { type: Function, default: null },
44
+ t: { type: Function, required: true },
45
+ },
46
+ emits: ["retry", "open"],
47
+ };
48
+ </script>
49
+
50
+ <style scoped>
51
+ /* Covers the transcript and the composer instead of replacing them, so the
52
+ reader's place in the conversation survives a trip through the tabs. */
53
+ .desktop-preview { position:absolute; z-index:8; display:flex; min-width:0; min-height:0; flex-direction:column; inset:0; padding:12px 20px 20px; background:var(--q-background-1); }
54
+ .desktop-preview__bar { display:flex; min-width:0; flex:none; align-items:center; gap:12px; padding-bottom:10px; }
55
+ .desktop-preview__path { min-width:0; flex:1; overflow:hidden; padding:0 4px; color:var(--q-ink-3); font-size:12px; line-height:18px; text-overflow:ellipsis; white-space:nowrap; }
56
+ .desktop-preview__download { flex:none; color:var(--q-blue-default); font-size:12px; line-height:18px; text-decoration:none; }
57
+ .desktop-preview__content { display:flex; min-height:0; flex:1; flex-direction:column; overflow:hidden; border:1px solid var(--q-separator); border-radius:14px; background:var(--q-background-2); }
58
+ </style>
@@ -0,0 +1,82 @@
1
+ <template>
2
+ <div class="preview-tabs" role="tablist" :aria-label="t('tabs')">
3
+ <button
4
+ type="button"
5
+ role="tab"
6
+ class="preview-tabs__chat"
7
+ :data-active="active ? 'false' : 'true'"
8
+ :aria-selected="!active"
9
+ @click="$emit('chat')"
10
+ >
11
+ {{ t("chat") }}
12
+ </button>
13
+ <span class="preview-tabs__divider" aria-hidden="true" />
14
+ <TransitionGroup name="lares-tab" tag="div" class="preview-tabs__list">
15
+ <div
16
+ v-for="tab in tabs"
17
+ :key="tab.path"
18
+ class="preview-tabs__tab"
19
+ :data-active="tab.path === active ? 'true' : 'false'"
20
+ role="tab"
21
+ :aria-selected="tab.path === active"
22
+ >
23
+ <button
24
+ type="button"
25
+ class="preview-tabs__label"
26
+ @click="$emit('activate', tab.path)"
27
+ >
28
+ <svg viewBox="0 0 16 16" aria-hidden="true">
29
+ <path d="M4 1.75h5l3 3V14.25H4zM9 1.75v3h3" />
30
+ </svg>
31
+ <span>{{ tab.name }}</span>
32
+ </button>
33
+ <button
34
+ type="button"
35
+ class="preview-tabs__close"
36
+ :aria-label="t('close', { name: tab.name })"
37
+ @click.stop="$emit('close', tab.path)"
38
+ >
39
+ <svg viewBox="0 0 16 16" aria-hidden="true">
40
+ <path d="m4.5 4.5 7 7m0-7-7 7" />
41
+ </svg>
42
+ </button>
43
+ </div>
44
+ </TransitionGroup>
45
+ </div>
46
+ </template>
47
+
48
+ <script>
49
+ export default {
50
+ name: "LaresDesktopPreviewTabs",
51
+ props: {
52
+ tabs: { type: Array, required: true },
53
+ // Empty while the reader is on the chat, so no file tab looks selected.
54
+ active: { type: String, default: "" },
55
+ t: { type: Function, required: true },
56
+ },
57
+ emits: ["chat", "activate", "close"],
58
+ };
59
+ </script>
60
+
61
+ <style scoped>
62
+ .preview-tabs { display:flex; min-width:0; flex:1; align-items:center; gap:8px; }
63
+ .preview-tabs__chat { height:24px; flex:none; padding:0 8px; border:0; border-radius:7px; background:transparent; color:var(--q-ink-3); cursor:pointer; font-size:12px; font-weight:500; }
64
+ .preview-tabs__chat:hover { background:var(--q-background-hover); color:var(--q-ink-1); }
65
+ .preview-tabs__chat[data-active="true"] { background:var(--q-background-3); color:var(--q-blue-default); }
66
+ .preview-tabs__divider { width:1px; height:16px; flex:none; background:var(--q-separator); }
67
+ .preview-tabs__list { position:relative; display:flex; min-width:0; flex:1; align-items:center; gap:6px; overflow-x:auto; scrollbar-width:none; }
68
+ .preview-tabs__chat,
69
+ .preview-tabs__tab,
70
+ .preview-tabs__label,
71
+ .preview-tabs__close { transition:background var(--lares-duration-fast, 120ms) var(--lares-ease-out, ease), color var(--lares-duration-fast, 120ms) var(--lares-ease-out, ease), border-color var(--lares-duration-fast, 120ms) var(--lares-ease-out, ease); }
72
+ .preview-tabs__list::-webkit-scrollbar { display:none; }
73
+ .preview-tabs__tab { display:inline-flex; max-width:180px; height:24px; flex:0 0 auto; align-items:center; padding:0 2px 0 8px; border:1px solid transparent; border-radius:7px; background:var(--q-background-3); color:var(--q-ink-2); }
74
+ .preview-tabs__tab:hover { background:var(--q-background-hover); color:var(--q-ink-1); }
75
+ .preview-tabs__tab[data-active="true"] { border-color:var(--q-input-stroke); background:var(--q-background-1); color:var(--q-ink-1); }
76
+ .preview-tabs__label { display:inline-flex; min-width:0; flex:1; align-items:center; gap:5px; padding:0; border:0; background:transparent; color:inherit; cursor:pointer; font-size:12px; font-weight:500; }
77
+ .preview-tabs__label svg { width:13px; height:13px; flex:none; fill:none; stroke:currentColor; stroke-linecap:round; stroke-linejoin:round; stroke-width:1.3; }
78
+ .preview-tabs__label span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
79
+ .preview-tabs__close { display:grid; width:18px; height:18px; flex:none; place-items:center; padding:0; border:0; border-radius:5px; background:transparent; color:var(--q-ink-3); cursor:pointer; }
80
+ .preview-tabs__close:hover { background:var(--q-background-hover); color:var(--q-red-default); }
81
+ .preview-tabs__close svg { width:11px; height:11px; fill:none; stroke:currentColor; stroke-linecap:round; stroke-width:1.8; }
82
+ </style>
@@ -0,0 +1,118 @@
1
+ <template>
2
+ <section class="desktop-questions">
3
+ <div class="desktop-questions__strip">{{ current.header || t("question.waiting") }}</div>
4
+ <div class="desktop-questions__body">
5
+ <strong>{{ current.question }}</strong>
6
+ <p v-if="current.detail">{{ current.detail }}</p>
7
+ <label v-for="option in current.options || []" :key="option.label" class="desktop-questions__option">
8
+ <input
9
+ :type="current.multiSelect ? 'checkbox' : 'radio'"
10
+ :name="current.id"
11
+ :value="option.label"
12
+ :checked="selected(current.id).includes(option.label)"
13
+ @change="toggle(option.label, $event.target.checked)"
14
+ />
15
+ <span><b>{{ option.label }}</b><small v-if="option.description">{{ option.description }}</small></span>
16
+ </label>
17
+ <label class="desktop-questions__other">
18
+ <span>{{ t("question.other") }}</span>
19
+ <input v-model="answers[current.id].custom" type="text" />
20
+ </label>
21
+ </div>
22
+ <footer>
23
+ <button v-if="index > 0" type="button" :disabled="busy" @click="index -= 1">{{ t("question.back") }}</button>
24
+ <span />
25
+ <button
26
+ v-if="index < questions.length - 1"
27
+ type="button"
28
+ :disabled="!answered || busy"
29
+ @click="index += 1"
30
+ >{{ t("question.next") }}</button>
31
+ <button v-else type="button" :disabled="!complete || busy" @click="submit">{{ t("question.submit") }}</button>
32
+ </footer>
33
+ </section>
34
+ </template>
35
+
36
+ <script>
37
+ function blankAnswers(questions) {
38
+ return Object.fromEntries(questions.map((row) => [row.id, { selected: [], custom: "" }]));
39
+ }
40
+
41
+ export default {
42
+ name: "LaresDesktopQuestions",
43
+ props: {
44
+ questions: { type: Array, required: true },
45
+ busy: { type: Boolean, default: false },
46
+ t: { type: Function, required: true },
47
+ },
48
+ emits: ["answer"],
49
+ data() {
50
+ return { index: 0, answers: blankAnswers(this.questions) };
51
+ },
52
+ computed: {
53
+ current() {
54
+ return this.questions[this.index] ?? {};
55
+ },
56
+ answered() {
57
+ const row = this.answers[this.current.id];
58
+ return Boolean(row && (row.selected.length || row.custom.trim()));
59
+ },
60
+ complete() {
61
+ return this.questions.every((question) => {
62
+ const row = this.answers[question.id];
63
+ return Boolean(row && (row.selected.length || row.custom.trim()));
64
+ });
65
+ },
66
+ },
67
+ watch: {
68
+ questions() {
69
+ this.index = 0;
70
+ this.answers = blankAnswers(this.questions);
71
+ },
72
+ },
73
+ methods: {
74
+ selected(id) {
75
+ return this.answers[id]?.selected ?? [];
76
+ },
77
+ toggle(label, checked) {
78
+ const id = this.current.id;
79
+ const current = this.answers[id];
80
+ current.selected = this.current.multiSelect
81
+ ? (checked ? [...current.selected, label] : current.selected.filter((row) => row !== label))
82
+ : (checked ? [label] : []);
83
+ },
84
+ submit() {
85
+ if (!this.complete) return;
86
+ this.$emit("answer", {
87
+ answers: this.questions.map((question) => {
88
+ const row = this.answers[question.id];
89
+ return {
90
+ id: question.id,
91
+ selected: row.selected,
92
+ ...(row.custom.trim() ? { custom: row.custom.trim() } : {}),
93
+ };
94
+ }),
95
+ });
96
+ },
97
+ },
98
+ };
99
+ </script>
100
+
101
+ <style scoped>
102
+ .desktop-questions { 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%); }
103
+ .desktop-questions__strip { height:30px; padding:0 12px; background:var(--q-background-3); color:var(--q-ink-3); font-size:12px; line-height:30px; }
104
+ .desktop-questions__body { display:flex; max-height:240px; flex-direction:column; gap:8px; overflow:auto; padding:14px 16px; color:var(--q-ink-2); font-size:13px; }
105
+ .desktop-questions__body > strong { color:var(--q-ink-1); font-size:14px; }
106
+ .desktop-questions__body > p { margin:0; white-space:pre-wrap; }
107
+ .desktop-questions__option { display:flex; gap:9px; border:1px solid var(--q-separator); border-radius:8px; padding:8px; cursor:pointer; }
108
+ .desktop-questions__option span { display:flex; flex-direction:column; gap:2px; }
109
+ .desktop-questions__option b { color:var(--q-ink-1); font-weight:500; }
110
+ .desktop-questions__option small { color:var(--q-ink-3); }
111
+ .desktop-questions__other { display:flex; align-items:center; gap:8px; }
112
+ .desktop-questions__other input { min-width:0; height:28px; flex:1; border:1px solid var(--q-input-stroke); border-radius:6px; padding:0 8px; background:var(--q-background-1); color:var(--q-ink-1); }
113
+ .desktop-questions footer { display:flex; align-items:center; gap:8px; padding:8px 12px 12px; }
114
+ .desktop-questions footer span { flex:1; }
115
+ .desktop-questions footer 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; }
116
+ .desktop-questions footer button:last-child { border-color:transparent; background:var(--q-blue-default); color:var(--q-ink-on-brand); }
117
+ .desktop-questions footer button:disabled { opacity:.45; cursor:default; }
118
+ </style>
@@ -0,0 +1,203 @@
1
+ <template>
2
+ <Transition name="lares-panel">
3
+ <div v-if="rows.length" class="desktop-queue">
4
+ <div class="desktop-queue__panel">
5
+ <button
6
+ v-if="rows.length > 1"
7
+ type="button"
8
+ class="desktop-queue__header"
9
+ :aria-expanded="expanded"
10
+ :disabled="Boolean(editing || busy)"
11
+ @click="collapsed = !collapsed"
12
+ >
13
+ <QueueIcon />
14
+ <span>{{ t("queue.count", { n: rows.length }) }}</span>
15
+ <ChevronIcon :class="{ 'is-open': expanded }" />
16
+ </button>
17
+ <ul
18
+ class="desktop-queue__list"
19
+ :data-expanded="rows.length === 1 || expanded ? 'true' : 'false'"
20
+ >
21
+ <li v-for="row in rows" :key="row.id" class="desktop-queue__row">
22
+ <QueueIcon v-if="rows.length === 1" class="desktop-queue__lead" />
23
+ <input
24
+ v-if="editing?.id === row.id"
25
+ ref="editor"
26
+ v-model="editing.text"
27
+ class="desktop-queue__editor"
28
+ :aria-label="t('queue.edit')"
29
+ @keydown.esc="editing = null"
30
+ @keydown.enter.exact.prevent="saveEdit"
31
+ />
32
+ <span v-else class="desktop-queue__preview">{{ row.preview }}</span>
33
+ <div class="desktop-queue__actions">
34
+ <template v-if="editing?.id === row.id">
35
+ <ActionButton
36
+ :label="t('queue.save')"
37
+ :disabled="Boolean(busy) || !editing.text.trim()"
38
+ icon="check"
39
+ @click="saveEdit"
40
+ />
41
+ <ActionButton
42
+ :label="t('queue.cancelEdit')"
43
+ :disabled="Boolean(busy)"
44
+ icon="close"
45
+ @click="editing = null"
46
+ />
47
+ </template>
48
+ <template v-else>
49
+ <ActionButton
50
+ :label="t('queue.edit')"
51
+ :disabled="Boolean(busy) || row.text == null"
52
+ icon="edit"
53
+ @click="beginEdit(row)"
54
+ />
55
+ <ActionButton
56
+ :label="t('queue.remove')"
57
+ :disabled="Boolean(busy)"
58
+ icon="trash"
59
+ @click="$emit('action', row.id, { kind: 'remove' })"
60
+ />
61
+ <ActionButton
62
+ :label="t('queue.steer')"
63
+ :disabled="Boolean(busy) || !running"
64
+ icon="send"
65
+ @click="$emit('action', row.id, { kind: 'steer' })"
66
+ />
67
+ </template>
68
+ </div>
69
+ </li>
70
+ </ul>
71
+ </div>
72
+ </div>
73
+ </Transition>
74
+ </template>
75
+
76
+ <script>
77
+ import { h } from "vue";
78
+ import LaresPcTooltip from "./ui/PcTooltip.vue";
79
+
80
+ const paths = {
81
+ check: "m4 8 2.5 2.5L12 5",
82
+ close: "m4.5 4.5 7 7m0-7-7 7",
83
+ edit: "M3.5 11.8V13h1.2l7.7-7.7-1.7-1.7z",
84
+ trash: "M4 5h8M6 5V3.5h4V5m-5 0 .6 8h4.8l.6-8",
85
+ send: "m3 3 10 5-10 5 2-5z",
86
+ };
87
+
88
+ const glyph = (name, path, extra = {}) => ({
89
+ name,
90
+ render() {
91
+ return h("svg", { viewBox: "0 0 16 16", "aria-hidden": "true", ...extra }, [
92
+ h("path", { d: path }),
93
+ ]);
94
+ },
95
+ });
96
+
97
+ const QueueIcon = glyph("QueueIcon", "M3 4.5h10M3 8h7M3 11.5h4");
98
+ const ChevronIcon = glyph("ChevronIcon", "m4 6 4 4 4-4");
99
+
100
+ const ActionButton = {
101
+ name: "QueueActionButton",
102
+ components: { LaresPcTooltip },
103
+ props: {
104
+ label: { type: String, required: true },
105
+ icon: { type: String, required: true },
106
+ disabled: { type: Boolean, default: false },
107
+ },
108
+ emits: ["click"],
109
+ render() {
110
+ return h(LaresPcTooltip, { label: this.label, placement: "bottom" }, {
111
+ default: () => h("button", {
112
+ type: "button",
113
+ class: "desktop-queue__action",
114
+ disabled: this.disabled,
115
+ "aria-label": this.label,
116
+ onClick: () => this.$emit("click"),
117
+ }, [
118
+ h("svg", { viewBox: "0 0 16 16", "aria-hidden": "true" }, [
119
+ h("path", { d: paths[this.icon] }),
120
+ ]),
121
+ ]),
122
+ });
123
+ },
124
+ };
125
+
126
+ export default {
127
+ name: "LaresDesktopQueue",
128
+ components: { QueueIcon, ChevronIcon, ActionButton },
129
+ props: {
130
+ items: { type: Array, default: () => [] },
131
+ running: { type: Boolean, default: false },
132
+ busy: { type: String, default: "" },
133
+ t: { type: Function, required: true },
134
+ },
135
+ emits: ["action"],
136
+ data() {
137
+ return { collapsed: true, editing: null };
138
+ },
139
+ computed: {
140
+ rows() {
141
+ return this.items.filter((row) => row?.placement === "queued");
142
+ },
143
+ expanded() {
144
+ return !this.collapsed || Boolean(this.editing || this.busy);
145
+ },
146
+ },
147
+ watch: {
148
+ rows(next) {
149
+ if (!next.length) this.collapsed = true;
150
+ if (this.editing && !next.some((row) => row.id === this.editing.id)) this.editing = null;
151
+ },
152
+ },
153
+ methods: {
154
+ beginEdit(row) {
155
+ if (row.text == null) return;
156
+ this.editing = { id: row.id, text: row.text };
157
+ this.$nextTick(() => {
158
+ const editor = Array.isArray(this.$refs.editor) ? this.$refs.editor[0] : this.$refs.editor;
159
+ editor?.focus?.();
160
+ });
161
+ },
162
+ saveEdit() {
163
+ if (!this.editing?.text.trim()) return;
164
+ const { id, text } = this.editing;
165
+ this.$emit("action", id, { kind: "edit", content: [{ type: "text", text }] });
166
+ this.editing = null;
167
+ },
168
+ },
169
+ };
170
+ </script>
171
+
172
+ <style scoped>
173
+ .desktop-queue { box-sizing:border-box; width:calc(100% - 24px); margin:0 auto -3px; padding:0 8px; }
174
+ .desktop-queue__panel { overflow:hidden; border:1px solid var(--q-separator); border-bottom:0; border-radius:12px 12px 0 0; padding:2px 0; background:var(--q-background-3); }
175
+ .desktop-queue__header { display:flex; width:100%; height:36px; align-items:center; gap:10px; border:0; padding:4px 12px; background:transparent; color:var(--q-ink-1); text-align:left; cursor:pointer; }
176
+ .desktop-queue__header span { min-width:0; flex:1; font-size:13px; font-weight:500; }
177
+ .desktop-queue__header .is-open { transform:rotate(180deg); }
178
+ .desktop-queue__header > svg,
179
+ .desktop-queue__header .is-open { transition:transform var(--lares-duration-fast, 120ms) var(--lares-ease-out, ease); }
180
+ .desktop-queue__list { max-height:0; overflow:hidden; margin:0; padding:0; list-style:none; opacity:0; transition:max-height var(--lares-duration-normal, 220ms) var(--lares-ease-out, cubic-bezier(0.32, 0.72, 0, 1)), opacity var(--lares-duration-normal, 220ms) var(--lares-ease-out, cubic-bezier(0.32, 0.72, 0, 1)); }
181
+ .desktop-queue__list[data-expanded="true"] { max-height:180px; overflow-y:auto; opacity:1; }
182
+ @media (prefers-reduced-motion: reduce) {
183
+ .desktop-queue__list,
184
+ .desktop-queue__header > svg,
185
+ .desktop-queue__header .is-open { transition:none; }
186
+ .desktop-queue__list[data-expanded="false"] { opacity:1; }
187
+ }
188
+ .desktop-queue__row { display:flex; box-sizing:border-box; width:100%; height:36px; align-items:center; gap:10px; padding:4px 5px 4px 12px; }
189
+ .desktop-queue__row + .desktop-queue__row { border-top:1px solid var(--q-separator); }
190
+ .desktop-queue__lead,
191
+ .desktop-queue__header > svg { flex:none; color:var(--q-ink-3); }
192
+ .desktop-queue__preview,
193
+ .desktop-queue__editor { min-width:0; flex:1; font:inherit; font-size:13px; }
194
+ .desktop-queue__preview { overflow:hidden; color:var(--q-ink-2); text-overflow:ellipsis; white-space:nowrap; }
195
+ .desktop-queue__editor { box-sizing:border-box; height:28px; border:1px solid var(--q-input-stroke); border-radius:6px; padding:0 8px; outline:none; background:var(--q-background-1); color:var(--q-ink-1); }
196
+ .desktop-queue__editor:focus { border-color:var(--q-blue-default); }
197
+ .desktop-queue__actions { display:flex; flex:none; align-items:center; gap:4px; }
198
+ :deep(.desktop-queue__action) { display:grid; width:28px; height:28px; place-items:center; border:0; border-radius:50%; padding:0; background:transparent; color:var(--q-ink-3); cursor:pointer; }
199
+ :deep(.desktop-queue__action:hover:not(:disabled)) { background:var(--q-background-hover); color:var(--q-ink-2); }
200
+ :deep(.desktop-queue__action:disabled) { opacity:.45; cursor:default; }
201
+ .desktop-queue svg,
202
+ :deep(.desktop-queue__action svg) { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:1.4; stroke-linecap:round; stroke-linejoin:round; }
203
+ </style>