feat: support fullscreen AI assistant

This commit is contained in:
jxxghp
2026-08-09 16:36:04 +08:00
parent a98271f8a1
commit b2c4a69173
5 changed files with 130 additions and 4 deletions

View File

@@ -228,6 +228,7 @@ const historyLoading = ref(false)
const historyLoadingMore = ref(false)
const historyPage = ref(1)
const historyHasMore = ref(true)
const fullscreen = ref(false)
const slashCommands = ref<AgentSlashCommand[]>([])
const slashCommandsLoading = ref(false)
const slashCommandsLoaded = ref(false)
@@ -293,8 +294,10 @@ const recordingTimeText = computed(() => {
return `${minutes}:${String(remainSeconds).padStart(2, '0')}`
})
// 窄屏下直接全屏,避免聊天内容被压成半屏窄栏
const drawerWidth = computed(() => (display.mdAndDown.value ? '100vw' : '30rem'))
// 窄屏下直接全屏,桌面端则允许用户按需扩展对话区域
const drawerWidth = computed(() => (display.mdAndDown.value || fullscreen.value ? '100vw' : '30rem'))
// 仅桌面宽屏展示全屏开关,窄屏已默认占满视口。
const canToggleFullscreen = computed(() => !display.mdAndDown.value)
const hasMessages = computed(() => messages.value.length > 0)
const hasHistorySessions = computed(() => historySessions.value.length > 0)
const currentUserName = computed(() => userStore.getUserName || t('common.user'))
@@ -2180,6 +2183,11 @@ function closeDrawer() {
isOpen.value = false
}
// 在桌面侧栏与全屏对话模式之间切换。
function toggleFullscreen() {
fullscreen.value = !fullscreen.value
}
// 同步面板打开状态到全局 DOM供悬浮入口避让面板宽度。
function syncAgentAssistantOpenState(isOpen: boolean) {
if (typeof document === 'undefined') return
@@ -2238,10 +2246,12 @@ function handleInputKeydown(event: KeyboardEvent) {
sendMessage()
}
// 开始输入法组合输入,避免回车确认候选词时误发送消息。
function handleCompositionStart() {
isComposing.value = true
}
// 结束输入法组合输入,恢复回车发送能力。
function handleCompositionEnd() {
isComposing.value = false
}
@@ -2288,7 +2298,11 @@ onScopeDispose(() => {
<aside
v-show="isOpen"
class="agent-assistant-panel"
:class="{ 'is-motion-paused': !props.motionActive, 'is-open': isOpen }"
:class="{
'is-fullscreen': fullscreen,
'is-motion-paused': !props.motionActive,
'is-open': isOpen,
}"
:style="drawerStyle"
role="dialog"
:aria-label="t('agentAssistant.title')"
@@ -2404,6 +2418,16 @@ onScopeDispose(() => {
>
<VIcon icon="mdi-message-plus-outline" />
</IconBtn>
<IconBtn
v-if="canToggleFullscreen"
class="agent-assistant-fullscreen-toggle"
:title="t(fullscreen ? 'agentAssistant.exitFullscreen' : 'agentAssistant.enterFullscreen')"
:aria-label="t(fullscreen ? 'agentAssistant.exitFullscreen' : 'agentAssistant.enterFullscreen')"
:aria-pressed="fullscreen"
@click="toggleFullscreen"
>
<VIcon :icon="fullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'" />
</IconBtn>
<IconBtn :title="t('common.close')" :aria-label="t('common.close')" @click="closeDrawer">
<VIcon icon="mdi-close" />
</IconBtn>
@@ -2713,6 +2737,27 @@ onScopeDispose(() => {
overscroll-behavior: contain;
}
.agent-assistant-panel.is-fullscreen {
border-inline-start: 0;
box-shadow: none;
.agent-assistant-message__bubble {
max-inline-size: min(100%, 64rem);
}
.agent-assistant-segments,
.agent-assistant-choices,
.agent-assistant-attachments {
inline-size: min(100%, 64rem);
}
.agent-assistant-composer {
inline-size: min(calc(100% - 2rem), 72rem);
inset-inline: 0;
margin-inline: auto;
}
}
@supports (block-size: 100lvh) {
.agent-assistant-panel {
block-size: 100lvh !important;
@@ -3048,6 +3093,11 @@ onScopeDispose(() => {
min-block-size: 100%;
}
.agent-assistant-panel.is-fullscreen .agent-assistant-messages__content {
inline-size: min(100%, 72rem);
margin-inline: auto;
}
/* 只有消息态预留输入框空间,避免 iOS 空态被 padding 撑出不可滚动的滚动条。 */
.agent-assistant-messages--has-content {
padding-block-end: calc(env(safe-area-inset-bottom, 0px) + 8.75rem);

View File

@@ -2,12 +2,14 @@ import { flushPromises, shallowMount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AgentAssistantPanel from '@/components/agent/AgentAssistantPanel.vue'
const displayState = vi.hoisted(() => ({ mdAndDown: { value: true } }))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('vuetify', () => ({
useDisplay: () => ({ mdAndDown: { value: true } }),
useDisplay: () => displayState,
}))
vi.mock('@/stores', () => ({
@@ -47,9 +49,77 @@ describe('AgentAssistantPanel stream recovery', () => {
})
afterEach(() => {
displayState.mdAndDown.value = true
vi.useRealTimers()
})
it('toggles the desktop assistant between the side panel and fullscreen modes', async () => {
displayState.mdAndDown.value = false
vi.stubGlobal(
'fetch',
vi.fn(async () => createAgentResponse([])),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await flushPromises()
const panel = wrapper.get('.agent-assistant-panel')
const fullscreenToggle = wrapper.get('.agent-assistant-fullscreen-toggle')
expect(panel.classes()).not.toContain('is-fullscreen')
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 30rem')
expect(fullscreenToggle.attributes('title')).toBe('agentAssistant.enterFullscreen')
expect(fullscreenToggle.attributes('aria-pressed')).toBe('false')
await fullscreenToggle.trigger('click')
expect(panel.classes()).toContain('is-fullscreen')
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 100vw')
expect(fullscreenToggle.attributes('title')).toBe('agentAssistant.exitFullscreen')
expect(fullscreenToggle.attributes('aria-pressed')).toBe('true')
await fullscreenToggle.trigger('click')
expect(panel.classes()).not.toContain('is-fullscreen')
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 30rem')
wrapper.unmount()
})
it('keeps the narrow assistant fullscreen without a redundant toggle', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => createAgentResponse([])),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await flushPromises()
expect(wrapper.get('.agent-assistant-panel').attributes('style')).toContain('--agent-assistant-panel-width: 100vw')
expect(wrapper.find('.agent-assistant-fullscreen-toggle').exists()).toBe(false)
wrapper.unmount()
})
it('restores a legacy empty message to loading and waits for the final reply after a PWA reload', async () => {
const startedAt = Date.now() - 5000
const localSessionId = 'web-local-session'

View File

@@ -847,6 +847,8 @@ export default {
ready: 'Ready',
thinking: 'Thinking',
newChat: 'New Chat',
enterFullscreen: 'Enter fullscreen',
exitFullscreen: 'Exit fullscreen',
history: 'Chat History',
historyLoading: 'Loading chat history...',
historyLoadFailed: 'Failed to load chat history',

View File

@@ -836,6 +836,8 @@ export default {
ready: '随时待命',
thinking: '思考中',
newChat: '新会话',
enterFullscreen: '全屏显示',
exitFullscreen: '退出全屏',
history: '历史会话',
historyLoading: '正在加载历史会话...',
historyLoadFailed: '历史会话加载失败',

View File

@@ -836,6 +836,8 @@ export default {
ready: '隨時待命',
thinking: '思考中',
newChat: '新會話',
enterFullscreen: '全屏顯示',
exitFullscreen: '退出全屏',
history: '歷史會話',
historyLoading: '正在載入歷史會話...',
historyLoadFailed: '歷史會話載入失敗',