Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,8 @@ const form = useForm({

### Icon Usage

Memoh 图标层的目标契约见 [`packages/icons/README.md`](../../packages/icons/README.md)。下述 Lucide / 品牌图标划分描述现有接入方式;新增光学校准或定制图标遵循该契约,在图标层实现,不在页面或菜单调用处补偿。现有直接导入在迁移期间保留。

- **Lucide** (primary): Direct component imports from `lucide-vue-next`. Example: `import { Plus, Search, Bot } from 'lucide-vue-next'` → `<Plus class="size-4" />`. Used for all UI icons (actions, navigation, status indicators, etc.).
- **`@memohai/icon`** (brand icons): Workspace package (`packages/icons/`) providing AI provider, search engine, and channel platform SVG icons as Vue components. Example: `import { Openai, Claude } from '@memohai/icon'`.
- **Do NOT use FontAwesome** for new code. Legacy FontAwesome usage remains only in commented-out code blocks. Always use Lucide for UI icons and `@memohai/icon` for brand logos.
Expand Down
50 changes: 43 additions & 7 deletions apps/web/src/components/computer/bot-computer-access-dialog.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<template>
<Dialog v-model:open="open">
<DialogContent>
<DialogPanel
width="xl"
footer
>
<DialogHeader class="pr-8">
<DialogTitle class="break-words">
{{ subjectName }}
Expand All @@ -10,27 +13,44 @@
</DialogDescription>
</DialogHeader>

<ComputerAccessList
:runtime="runtime"
:bot="bot"
/>
<DialogBody>
<ComputerAccessList
:runtime="runtime"
:bot="bot"
@add-computer="onAddComputer"
/>
</DialogBody>

<DialogFooter>
<!-- This dialog only grants access; computer lifecycle (connect,
delete) lives on the Computers settings page — the footer offers
the explicit exit instead of leaving users stranded. -->
<Button
v-if="subject === 'bot'"
variant="outline"
@click="goToManage"
>
<SettingsIcon />
{{ t('chat.continueOn.manageComputers') }}
</Button>
<Button @click="open = false">
{{ t('computerAccess.done') }}
</Button>
</DialogFooter>
</DialogContent>
</DialogPanel>
</Dialog>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { SettingsIcon } from '@memohai/icon/ui'
import {
Button,
Dialog,
DialogContent,
DialogPanel,
DialogBody,
DialogDescription,
DialogFooter,
DialogHeader,
Expand All @@ -48,7 +68,23 @@ const props = defineProps<{

const open = defineModel<boolean>('open', { default: false })

// The zero-state ghost row's add action is re-emitted, not run here: hosts
// v-if this dialog away the moment it closes, so a wizard mounted inside it
// would be destroyed before its credential round-trip resolves. The host owns
// the wizard on a surface that outlives this dialog.
const emit = defineEmits<{ addComputer: [] }>()

const { t } = useI18n()
const router = useRouter()

function onAddComputer(): void {
emit('addComputer')
}

function goToManage(): void {
open.value = false
void router.push({ name: 'runtimes' })
}

const subject = computed<'runtime' | 'bot'>(() => (props.runtime ? 'runtime' : 'bot'))
const subjectName = computed(() => (
Expand Down
41 changes: 23 additions & 18 deletions apps/web/src/components/computer/computer-access-list.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,18 @@
</div>

<template v-else>
<SettingsSection v-if="rows.length || subject === 'bot'">
<SettingsSection
v-if="rows.length || subject === 'bot'"
bordered
>
<!-- Bot direction always lists the native workspace first: it is part
of every bot and can never be revoked, so it gets a caption instead
of a switch. -->
of every bot and can never be revoked, so its switch stays enabled and cannot be edited. -->
<SettingsRow
v-if="subject === 'bot'"
:label="t('bots.remoteRuntime.nativeWorkspace')"
:description="t('computerAccess.nativeAlwaysOn')"
>
<template #leading>
<Cloud class="size-4 text-muted-foreground" />
<CloudIcon class="size-4 text-muted-foreground" />
</template>
<Switch
:model-value="true"
Expand Down Expand Up @@ -58,7 +59,7 @@
{{ avatarInitials(row.name) }}
</AvatarFallback>
</Avatar>
<Laptop
<ComputerIcon
v-else
class="size-4 text-muted-foreground"
/>
Expand All @@ -83,18 +84,23 @@
</div>
</SettingsRow>

<!-- Bot direction with zero account computers: the connect CTA lives in
the same frame, one row under the native workspace. -->
<!-- Bot direction with zero account computers: a ghost row standing in
for the computer the user could connect — it looks like a real row,
but the trailing slot is the add action, not a switch. -->
<SettingsRow
v-if="subject === 'bot' && rows.length === 0"
:label="t('computerAccess.emptyComputers')"
:label="t('computerAccess.yourComputer')"
:description="t('computerAccess.notConnected')"
>
<template #leading>
<ComputerIcon class="size-4 text-muted-foreground" />
</template>
<Button
variant="outline"
size="sm"
@click="goToRuntimes"
@click="emit('addComputer')"
>
{{ t('computerAccess.connectCta') }}
{{ t('chat.continueOn.addComputer') }}
</Button>
</SettingsRow>
</SettingsSection>
Expand All @@ -113,7 +119,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useQuery } from '@pinia/colada'
import type { BotsBot } from '@memohai/sdk'
import { getBotsQuery } from '@memohai/sdk/colada'
Expand All @@ -128,7 +133,7 @@ import {
Switch,
toast,
} from '@felinic/ui'
import { Cloud, Laptop } from 'lucide-vue-next'
import { CloudIcon, ComputerIcon } from '@memohai/icon/ui'
import { avatarInitials } from '@/composables/useAvatarInitials'
import { resolveApiErrorMessage } from '@/utils/api-error'
import { useAccountRuntimes, useComputerAccessActions, useComputerAccessGrants } from './use-computer-access'
Expand All @@ -142,6 +147,11 @@ const props = defineProps<{
bot?: { id: string, name: string } | null
}>()

const emit = defineEmits<{
/** The zero-state ghost row's add action — the host opens the connect wizard. */
addComputer: []
}>()

type AccessRow = {
key: string
botId: string
Expand All @@ -155,7 +165,6 @@ type AccessRow = {
)

const { t } = useI18n()
const router = useRouter()

const subject = computed<'runtime' | 'bot'>(() => (props.runtime ? 'runtime' : 'bot'))

Expand Down Expand Up @@ -244,8 +253,4 @@ function retry(): void {
if (subject.value === 'runtime') void refetchBots()
else void refetchRuntimes()
}

function goToRuntimes(): void {
void router.push({ name: 'runtimes' })
}
</script>
36 changes: 36 additions & 0 deletions apps/web/src/components/computer/use-connect-computer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMutation } from '@pinia/colada'
import { postUsersMeRuntimes, type UserruntimeRuntime } from '@memohai/sdk'
import { toast } from '@felinic/ui'
import { resolveApiErrorMessage } from '@/utils/api-error'
import { useAccountRuntimes } from './use-computer-access'

// Shared one-click connect flow for every "add a computer" entry point:
// create the credential, hand it to the stepper dialog (command → connected
// → permissions), and let that dialog own cancellation cleanup. Used by the
// composer menu and the access dialog's zero-state ghost row; the Computers
// page keeps its own copy because it also drives the this-machine form.
export function useConnectComputer() {
const { t } = useI18n()
const { refetch: refetchRuntimes } = useAccountRuntimes()

const open = ref(false)
const credential = ref<UserruntimeRuntime | null>(null)
const { mutateAsync: createRuntime, isLoading: creating } = useMutation({
mutation: async () => (await postUsersMeRuntimes({ body: { name: '' }, throwOnError: true })).data,
})

async function startConnect(): Promise<void> {
if (creating.value) return
try {
credential.value = await createRuntime()
open.value = true
void refetchRuntimes()
} catch (error) {
toast.error(resolveApiErrorMessage(error, t('runtimes.connectDialog.createFailed')))
}
}

return { connectOpen: open, connectCredential: credential, creating, startConnect }
}
2 changes: 2 additions & 0 deletions apps/web/src/components/provider-icon/icons.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Component } from 'vue'
import {
Slack,
Anthropic,
Azure,
AzureColor,
Expand Down Expand Up @@ -84,6 +85,7 @@ import {
* The key is the SVG filename without extension (e.g. 'openai', 'deepseek-color').
*/
export const iconMap: Record<string, Component> = {
'slack': Slack,
'openai': Openai,
'anthropic': Anthropic,
'github-copilot': GithubCopilot,
Expand Down
23 changes: 21 additions & 2 deletions apps/web/src/components/provider-icon/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,34 @@
v-bind="$attrs"
/>
<img
v-else-if="isUrl"
:src="icon"
v-else-if="imageSource"
:src="imageSource"
decoding="sync"
loading="eager"
:width="size"
:height="size"
alt=""
class="[color-scheme:light] dark:[color-scheme:dark]"
v-bind="$attrs"
>
<!-- URL icon still fetching: hold an empty, correctly-sized box instead of
the fallback slot. The fallback would paint at the glyph's default size
(it receives no $attrs) and then swap to the real image — a visible
flash + size jump on every uncached mount. Unknown non-URL names still
get the slot. -->
<span
v-else-if="isUrl"
class="inline-block"
v-bind="$attrs"
Comment on lines +24 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 为预加载占位符保留 size 尺寸

当 URL 图标调用方仅通过组件的 size 属性指定尺寸(例如 provider/voice 页面使用的 size="1.5em")时,这个空 span 没有应用 widthheight,加载期间实际为 0×0;fetch/decode 完成并换成带尺寸的 img 后仍会发生布局跳动,正好违背这里消除尺寸跳变的目的。占位符需要根据 size 设置宽高,而不能仅依赖可能不存在的 $attrs.class

Useful? React with 👍 / 👎.

aria-hidden="true"
/>
<slot v-else />
</template>

<script setup lang="ts">
import { computed, type Component } from 'vue'
import { iconMap } from './icons.ts'
import { providerIconSource } from './preload'

const props = withDefaults(defineProps<{
icon: string
Expand All @@ -34,6 +48,11 @@ const isUrl = computed(() =>
props.icon.startsWith('http://') || props.icon.startsWith('https://'),
)

const source = computed(() => isUrl.value && typeof Image !== 'undefined'
? providerIconSource(props.icon)
: undefined)
const imageSource = computed(() => source.value?.value || '')

const iconComponent = computed<Component | undefined>(() => {
if (isUrl.value) return undefined
return iconMap[props.icon]
Expand Down
69 changes: 69 additions & 0 deletions apps/web/src/components/provider-icon/preload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { afterEach, expect, it, vi } from 'vitest'

const decode = vi.fn<() => Promise<void>>()
const request = vi.fn<typeof fetch>()
class MockImage {
src = ''
decode = decode
}

function setup() {
vi.stubGlobal('Image', MockImage)
vi.stubGlobal('fetch', request)
decode.mockResolvedValue(undefined)
request.mockImplementation(async () => new Response('<svg xmlns="http://www.w3.org/2000/svg"/>', {
headers: { 'Content-Type': 'image/svg+xml' },
}))
}

afterEach(() => {
vi.unstubAllGlobals()
vi.resetModules()
decode.mockReset()
request.mockReset()
})

it('shares pending work and decoded bytes between preload and repeated mounts for any URL', async () => {
setup()
const { providerIconSource, preloadProviderIcons } = await import('./preload')
const url = 'https://custom.example/artwork.svg'
preloadProviderIcons([url, 'slack', undefined])
const first = providerIconSource(url)
expect(providerIconSource(url)).toBe(first)
await vi.waitFor(() => expect(first.value).toMatch(/^data:image\/svg\+xml;base64,/))
for (let i = 0; i < 20; i++) expect(providerIconSource(url).value).toBe(first.value)
expect(request).toHaveBeenCalledTimes(1)
expect(decode).toHaveBeenCalledTimes(1)
})

it('falls back to normal embedding on CORS failure and retries on a later mount', async () => {
setup()
request.mockRejectedValueOnce(new TypeError('Failed to fetch'))
const { providerIconSource } = await import('./preload')
const url = 'https://custom.example/no-cors.png'
const first = providerIconSource(url)
await vi.waitFor(() => expect(first.value).toBe(url))
const second = providerIconSource(url)
await vi.waitFor(() => expect(second.value).toMatch(/^data:/))
expect(request).toHaveBeenCalledTimes(2)
})

it('does not publish an undecodable data source', async () => {
setup()
decode.mockRejectedValueOnce(new Error('Invalid artwork'))
const { providerIconSource } = await import('./preload')
const url = 'https://custom.example/broken.svg'
const source = providerIconSource(url)
await vi.waitFor(() => expect(source.value).toBe(url))
})

it('evicts old cache entries without invalidating sources held by mounted consumers', async () => {
setup()
const { providerIconSource } = await import('./preload')
const first = providerIconSource('https://custom.example/first.svg')
await vi.waitFor(() => expect(first.value).toMatch(/^data:/))
const loaded = first.value
for (let i = 0; i < 128; i++) providerIconSource(`https://custom.example/${i}.svg`)
expect(first.value).toBe(loaded)
expect(providerIconSource('https://custom.example/first.svg')).not.toBe(first)
})
Loading
Loading