Skip to content

Commit 08f247e

Browse files
authored
Merge pull request #311 from nextcloud/feat/categories
feat: Add categories with icons
2 parents 0b0daa3 + 8f34380 commit 08f247e

File tree

5 files changed

+285
-66
lines changed

5 files changed

+285
-66
lines changed

src/components/AssistantTextProcessingModal.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export default {
115115
show: true,
116116
closeButtonTitle: t('assistant', 'Close'),
117117
closeButtonLabel: t('assistant', 'Close Nextcloud Assistant'),
118-
modalSize: 'full',
118+
modalSize: 'large',
119119
progress: null,
120120
loading: false,
121121
expectedRuntime: null,
@@ -191,6 +191,11 @@ export default {
191191
.modal-container__content .assistant-modal--wrapper {
192192
height: 100%;
193193
}
194+
195+
// make large modal a bit larger
196+
.assistant-modal .modal-container {
197+
width: 1220px !important;
198+
}
194199
</style>
195200
196201
<style lang="scss" scoped>
@@ -210,6 +215,7 @@ export default {
210215
211216
.assistant-modal--content {
212217
width: 100%;
218+
margin: 0 auto;
213219
padding: 16px;
214220
display: flex;
215221
flex-direction: column;

src/components/TaskTypeSelect.vue

Lines changed: 191 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5,43 +5,94 @@
55
<template>
66
<div ref="taskTypeSelect"
77
class="task-type-select">
8-
<NcButton v-for="(t, i) in buttonTypes"
9-
:key="i + t.id"
10-
:variant="getButtonType(t)"
11-
:title="t.description"
12-
@click="onTaskSelected(t)">
13-
{{ t.name }}
14-
</NcButton>
15-
<NcActions v-if="!onlyInline && actionTypes.length > 0"
16-
:force-menu="true"
17-
:container="$refs.taskTypeSelect">
18-
<NcActionButton v-for="(t, i) in actionTypes"
19-
:key="i + t.id"
20-
class="no-icon-action"
21-
:aria-label="t.name"
22-
:close-after-click="true"
23-
@click="onMenuTaskSelected(t)">
8+
<template v-for="variants in buttonTypesByInlineStatus.inline">
9+
<NcActions v-if="hasSubMenu(variants)"
10+
:key="variants.id"
11+
:force-menu="true"
12+
:menu-name="variants.text"
13+
:container="$refs.taskTypeSelect"
14+
:primary="isCategorySelected(variants)"
15+
@click="onMenuCategorySelected(variants)">
16+
<NcActionButton v-for="t in variants.tasks"
17+
:key="t.id"
18+
:disabled="selectedTask(t)"
19+
:title="t.description"
20+
:close-after-click="true"
21+
@click="onTaskSelected(t)">
22+
<template #icon>
23+
<div style="width: 16px" />
24+
</template>
25+
{{ t.name }}
26+
</NcActionButton>
27+
<template #icon>
28+
<component :is="variants.icon" />
29+
</template>
30+
</NcActions>
31+
<NcButton v-else
32+
:key="variants.id + '-button'"
33+
:variant="isCategorySelected(variants) ? 'primary' : 'secondary'"
34+
:title="variants.text"
35+
@click="onMenuCategorySelected(variants)">
2436
<template #icon>
25-
<div style="width: 16px" />
37+
<component :is="variants.icon" />
2638
</template>
27-
{{ t.name }}
28-
</NcActionButton>
39+
{{ variants.text }}
40+
</NcButton>
41+
</template>
42+
<NcActions
43+
:force-menu="true"
44+
:container="$refs.taskTypeSelect"
45+
@close="categorySubmenu = null">
46+
<template v-if="!categorySubMenuTaskType">
47+
<NcActionButton v-for="variant in buttonTypesByInlineStatus.overflow"
48+
:key="variant.id"
49+
:is-menu="variant.tasks.length > 1 || variant.id === 'other'"
50+
:title="variant.text"
51+
@click="onMenuCategorySelected(variant)">
52+
<template #icon>
53+
<component :is="variant.icon" />
54+
</template>
55+
{{ variant.text }}
56+
</NcActionButton>
57+
</template>
58+
<template v-else>
59+
<NcActionButton v-for="t in categorySubMenuTaskType.tasks"
60+
:key="t.id"
61+
:disabled="selectedTask(t)"
62+
:title="t.description"
63+
:close-after-click="true"
64+
@click="onTaskSelected(t)">
65+
<template #icon>
66+
<div style="width: 16px" />
67+
</template>
68+
{{ t.name }}
69+
</NcActionButton>
70+
</template>
2971
</NcActions>
3072
</div>
3173
</template>
3274

3375
<script>
34-
import NcButton from '@nextcloud/vue/components/NcButton'
3576
import NcActions from '@nextcloud/vue/components/NcActions'
77+
import NcButton from '@nextcloud/vue/components/NcButton'
3678
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
79+
import MessageOutlineIcon from 'vue-material-design-icons/MessageOutline.vue'
80+
import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
81+
import TextLongIcon from 'vue-material-design-icons/TextLong.vue'
82+
import ImageOutlineIcon from 'vue-material-design-icons/ImageOutline.vue'
83+
import WebIcon from 'vue-material-design-icons/Web.vue'
84+
import FileIcon from 'vue-material-design-icons/File.vue'
85+
import ContentPasteSearchIcon from './icons/ContentPasteSearch.vue'
86+
import WaveformIcon from './icons/Waveform.vue'
3787
3888
export default {
3989
name: 'TaskTypeSelect',
4090
4191
components: {
42-
NcButton,
4392
NcActions,
4493
NcActionButton,
94+
MessageOutlineIcon,
95+
NcButton,
4596
},
4697
4798
props: {
@@ -69,7 +120,7 @@ export default {
69120
70121
data() {
71122
return {
72-
extraButtonType: null,
123+
categorySubmenu: null,
73124
}
74125
},
75126
@@ -78,66 +129,142 @@ export default {
78129
return this.inline === null
79130
},
80131
buttonTypes() {
81-
if (this.onlyInline) {
82-
return this.options
132+
const taskTypes = {}
133+
for (const task of this.options) {
134+
const type = this.getTaskCategory(task.id)
135+
if (!taskTypes[type]) {
136+
taskTypes[type] = []
137+
}
138+
taskTypes[type].push(task)
83139
}
84-
// extra button replaces the last one
85-
if (this.extraButtonType !== null) {
86-
const types = this.options.slice(0, this.inline - 1)
87-
types.push(this.extraButtonType)
88-
return types
89-
} else {
90-
return this.options.slice(0, this.inline)
140+
const result = []
141+
for (const entry of Object.entries(taskTypes)) {
142+
if (entry[0] === 'other') {
143+
continue
144+
}
145+
result.push({
146+
id: entry[0],
147+
text: this.getTextForCategory(entry[0]),
148+
icon: this.getCategoryIcon(entry[0]),
149+
tasks: entry[1],
150+
})
151+
}
152+
// Ensure the "other" category is always last
153+
if (taskTypes.other) {
154+
result.push({
155+
id: 'other',
156+
text: this.getTextForCategory('other'),
157+
icon: this.getCategoryIcon('other'),
158+
tasks: taskTypes.other,
159+
})
91160
}
161+
return result
92162
},
93-
actionTypes() {
94-
if (this.extraButtonType !== null) {
95-
// the extra button replaces the last one so we need the last one as an action
96-
// take all non-inline options that are not selected and that are not the extra button
97-
const types = this.options.slice(this.inline).filter(t => t.id !== this.modelValue && t.id !== this.extraButtonType.id)
98-
// add the one that was a button and that has been replaced
99-
if (this.extraButtonType.id !== this.options[this.inline - 1].id) {
100-
types.unshift(this.options[this.inline - 1])
163+
buttonTypesByInlineStatus() {
164+
if (this.onlyInline) {
165+
return { inline: this.buttonTypes, overflow: [] }
166+
}
167+
const inlineButtonTypes = this.buttonTypes.slice(0, this.inline)
168+
let overflowButtonTypes = this.buttonTypes.slice(this.inline)
169+
170+
// Ensure that the selection is never inline otherwise swap with the last uninlined category
171+
const selection = overflowButtonTypes.find(t => this.isCategorySelected(t))
172+
if (selection) {
173+
const removal = inlineButtonTypes.pop()
174+
inlineButtonTypes.push(selection)
175+
overflowButtonTypes = overflowButtonTypes.filter(t => t.id !== selection.id)
176+
if (removal) {
177+
overflowButtonTypes.unshift(removal)
101178
}
102-
return types
103-
} else {
104-
return this.options.slice(this.inline)
105179
}
180+
return { overflow: overflowButtonTypes, inline: inlineButtonTypes }
106181
},
107-
},
108-
109-
watch: {
110-
options() {
111-
this.moveSelectedIfInMenu()
182+
categorySubMenuTaskType() {
183+
return this.buttonTypesByInlineStatus.overflow.find(t => t.id === this.categorySubmenu)
112184
},
113185
},
114186
115187
mounted() {
116-
this.moveSelectedIfInMenu()
117188
},
118189
119190
methods: {
120-
moveSelectedIfInMenu() {
121-
if (this.onlyInline) {
122-
return
123-
}
124-
// if the initially selected value is in the dropdown, get it out
125-
const selectedAction = this.actionTypes.find(a => a.id === this.modelValue)
126-
if (this.actionTypes.find(a => a.id === this.modelValue)) {
127-
this.extraButtonType = selectedAction
128-
}
129-
},
130-
getButtonType(taskType) {
191+
selectedTask(taskType) {
131192
return taskType.id === this.modelValue
132-
? 'primary'
133-
: 'secondary'
193+
},
194+
isCategorySelected(category) {
195+
return category.id === this.getTaskCategory(this.modelValue || '')
134196
},
135197
onTaskSelected(taskType) {
136198
this.$emit('update:model-value', taskType.id)
137199
},
138-
onMenuTaskSelected(taskType) {
139-
this.extraButtonType = taskType
140-
this.onTaskSelected(taskType)
200+
hasSubMenu(taskType) {
201+
return taskType.tasks.length > 1 || taskType.id === 'other'
202+
},
203+
onMenuCategorySelected(taskType) {
204+
if (this.hasSubMenu(taskType)) {
205+
this.categorySubmenu = taskType.id
206+
} else {
207+
this.onTaskSelected(taskType.tasks[0])
208+
this.categorySubmenu = null
209+
}
210+
},
211+
getTaskCategory(id) {
212+
if (id.startsWith('chatty')) {
213+
return 'chat'
214+
} else if (id.startsWith('context_chat')) {
215+
return 'context'
216+
} else if (id.includes('translate')) {
217+
return 'translate'
218+
} else if (id.startsWith('richdocuments')) {
219+
return 'generate'
220+
} else if (id.includes('image')) {
221+
return 'image'
222+
} else if (id.includes('audio') || id.includes('speech')) {
223+
return 'audio'
224+
} else if (id.includes('text')) {
225+
return 'text'
226+
}
227+
return 'other'
228+
},
229+
getTextForCategory(category) {
230+
switch (category) {
231+
case 'chat':
232+
return t('assistant', 'Chat with AI')
233+
case 'context':
234+
return t('assistant', 'Context Chat')
235+
case 'text':
236+
return t('assistant', 'Work with text')
237+
case 'image':
238+
return t('assistant', 'Work with images')
239+
case 'translate':
240+
return t('assistant', 'Translate')
241+
case 'audio':
242+
return t('assistant', 'Work with audio')
243+
case 'generate':
244+
return t('assistant', 'Generate file')
245+
default:
246+
return t('assistant', 'Other')
247+
}
248+
},
249+
getCategoryIcon(category) {
250+
switch (category) {
251+
case 'chat':
252+
return MessageOutlineIcon
253+
case 'context':
254+
return ContentPasteSearchIcon
255+
case 'text':
256+
return TextLongIcon
257+
case 'image':
258+
return ImageOutlineIcon
259+
case 'translate':
260+
return WebIcon
261+
case 'audio':
262+
return WaveformIcon
263+
case 'generate':
264+
return FileIcon
265+
default:
266+
return DotsHorizontalIcon
267+
}
141268
},
142269
},
143270
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
<template>
6+
<span v-bind="$attrs"
7+
:aria-hidden="title ? null : 'true'"
8+
:aria-label="title"
9+
class="material-design-icon waveform-icon"
10+
role="img"
11+
@click="$emit('click', $event)">
12+
<svg :fill="fillColor"
13+
class="material-design-icon__svg"
14+
:width="size"
15+
:height="size"
16+
viewBox="0 -960 960 960">
17+
<path d="M824-80 716-188q-22 13-46 20.5t-50 7.5q-75 0-127.5-52.5T440-340q0-75 52.5-127.5T620-520q75 0 127.5 52.5T800-340q0 26-7.5 50T772-244l108 108-56 56ZM620-240q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29Zm220-320h-80v-200h-80v120H280v-120h-80v560h200v80H200q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h167q11-35 43-57.5t70-22.5q40 0 71.5 22.5T594-840h166q33 0 56.5 23.5T840-760v200ZM480-760q17 0 28.5-11.5T520-800q0-17-11.5-28.5T480-840q-17 0-28.5 11.5T440-800q0 17 11.5 28.5T480-760Z">
18+
<title v-if="title">{{ title }}</title>
19+
</path>
20+
</svg>
21+
</span>
22+
</template>
23+
24+
<script>
25+
export default {
26+
name: 'ContentPasteSearch',
27+
props: {
28+
title: {
29+
type: String,
30+
default: null,
31+
},
32+
fillColor: {
33+
type: String,
34+
default: 'currentColor',
35+
},
36+
size: {
37+
type: Number,
38+
default: 24,
39+
},
40+
},
41+
emits: ['click'],
42+
}
43+
</script>

0 commit comments

Comments
 (0)