feat(web): persist assistant size and draggable position

This commit is contained in:
Hyu
2026-10-08 15:39:30 +08:00
parent b718c7b5dd
commit 6c6718d72b
12 changed files with 894 additions and 474 deletions
File diff suppressed because it is too large Load Diff
@@ -1,81 +1,20 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
useRef,
} from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { isNewerVersion } from '@/app/utils/versionCompare';
// Lightweight entity item for sidebar display
export interface SidebarEntityItem {
id: string;
name: string;
description?: string;
emoji?: string;
iconURL?: string;
updatedAt?: string; // ISO timestamp for sorting by most recently edited
// Bot-specific fields
enabled?: boolean;
legacyAdapter?: boolean;
// MCP-specific fields
runtimeStatus?: 'connecting' | 'connected' | 'error';
// Plugin-specific fields
installSource?: string;
installInfo?: Record<string, unknown>;
hasUpdate?: boolean;
debug?: boolean;
// Set when this item appears in the unified extensions list
extensionType?: 'plugin' | 'mcp' | 'skill';
// Agent-specific: distinguishes Agent processors from Pipelines
kind?: 'agent' | 'pipeline' | 'event_processor';
}
// Plugin page registered by a plugin
export interface PluginPageItem {
id: string; // "author/name/pageId"
name: string; // display label
pluginAuthor: string;
pluginName: string;
pluginLabel: string; // human-readable plugin display name
pluginIconURL: string; // plugin icon URL
pageId: string;
path: string; // asset path (HTML file)
}
// Entity lists and refresh functions exposed via context
export interface SidebarDataContextValue {
bots: SidebarEntityItem[];
pipelines: SidebarEntityItem[];
knowledgeBases: SidebarEntityItem[];
plugins: SidebarEntityItem[];
pluginCount: number;
mcpServers: SidebarEntityItem[];
skills: SidebarEntityItem[];
pluginPages: PluginPageItem[];
quotaDataLoaded: boolean;
refreshBots: () => Promise<void>;
refreshPipelines: () => Promise<void>;
refreshKnowledgeBases: () => Promise<void>;
refreshPlugins: () => Promise<void>;
refreshMCPServers: () => Promise<void>;
refreshSkills: () => Promise<void>;
refreshAll: () => Promise<void>;
// Breadcrumb: entity name shown when viewing a detail page
detailEntityName: string | null;
setDetailEntityName: (name: string | null) => void;
// Whether the extensions list is grouped by type (shared between page and sidebar)
extensionsGroupByType: boolean;
setExtensionsGroupByType: (enabled: boolean) => void;
// Whether the Agent list is grouped by kind (Agent vs Pipeline)
agentsGroupByKind: boolean;
setAgentsGroupByKind: (enabled: boolean) => void;
}
const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
// Keep the context identity independent of provider dependencies during HMR.
import {
SidebarDataContext,
type SidebarEntityItem,
type PluginPageItem,
} from './sidebarDataStore';
export { useSidebarData } from './sidebarDataStore';
export type {
SidebarEntityItem,
PluginPageItem,
SidebarDataContextValue,
} from './sidebarDataStore';
export function SidebarDataProvider({
children,
@@ -432,11 +371,3 @@ export function SidebarDataProvider({
</SidebarDataContext.Provider>
);
}
export function useSidebarData(): SidebarDataContextValue {
const ctx = useContext(SidebarDataContext);
if (!ctx) {
throw new Error('useSidebarData must be used within a SidebarDataProvider');
}
return ctx;
}
@@ -0,0 +1,78 @@
import { createContext, useContext } from 'react';
// Lightweight entity item for sidebar display
export interface SidebarEntityItem {
id: string;
name: string;
description?: string;
emoji?: string;
iconURL?: string;
updatedAt?: string; // ISO timestamp for sorting by most recently edited
// Bot-specific fields
enabled?: boolean;
legacyAdapter?: boolean;
// MCP-specific fields
runtimeStatus?: 'connecting' | 'connected' | 'error';
// Plugin-specific fields
installSource?: string;
installInfo?: Record<string, unknown>;
hasUpdate?: boolean;
debug?: boolean;
// Set when this item appears in the unified extensions list
extensionType?: 'plugin' | 'mcp' | 'skill';
// Agent-specific: distinguishes Agent processors from Pipelines
kind?: 'agent' | 'pipeline' | 'event_processor';
}
// Plugin page registered by a plugin
export interface PluginPageItem {
id: string; // "author/name/pageId"
name: string; // display label
pluginAuthor: string;
pluginName: string;
pluginLabel: string; // human-readable plugin display name
pluginIconURL: string; // plugin icon URL
pageId: string;
path: string; // asset path (HTML file)
}
// Entity lists and refresh functions exposed via context
export interface SidebarDataContextValue {
bots: SidebarEntityItem[];
pipelines: SidebarEntityItem[];
knowledgeBases: SidebarEntityItem[];
plugins: SidebarEntityItem[];
pluginCount: number;
mcpServers: SidebarEntityItem[];
skills: SidebarEntityItem[];
pluginPages: PluginPageItem[];
quotaDataLoaded: boolean;
refreshBots: () => Promise<void>;
refreshPipelines: () => Promise<void>;
refreshKnowledgeBases: () => Promise<void>;
refreshPlugins: () => Promise<void>;
refreshMCPServers: () => Promise<void>;
refreshSkills: () => Promise<void>;
refreshAll: () => Promise<void>;
// Breadcrumb: entity name shown when viewing a detail page
detailEntityName: string | null;
setDetailEntityName: (name: string | null) => void;
// Whether the extensions list is grouped by type (shared between page and sidebar)
extensionsGroupByType: boolean;
setExtensionsGroupByType: (enabled: boolean) => void;
// Whether the Agent list is grouped by kind (Agent vs Pipeline)
agentsGroupByKind: boolean;
setAgentsGroupByKind: (enabled: boolean) => void;
}
export const SidebarDataContext = createContext<SidebarDataContextValue | null>(
null,
);
export function useSidebarData(): SidebarDataContextValue {
const ctx = useContext(SidebarDataContext);
if (!ctx) {
throw new Error('useSidebarData must be used within a SidebarDataProvider');
}
return ctx;
}
+2
View File
@@ -1,6 +1,8 @@
import pipelineMigration from './pipeline-migration/en-US';
const enUS = {
assistant: {
movePosition: 'Move assistant horizontally',
resizeHeight: 'Resize assistant height',
sessions: 'Conversations',
currentChat: 'Current conversation',
stop: 'Stop task',
+2
View File
@@ -1,6 +1,8 @@
import pipelineMigration from './pipeline-migration/es-ES';
const esES = {
assistant: {
movePosition: 'Mover el asistente horizontalmente',
resizeHeight: 'Ajustar la altura del asistente',
sessions: 'Conversaciones',
currentChat: 'Conversación actual',
stop: 'Detener tarea',
+2
View File
@@ -1,6 +1,8 @@
import pipelineMigration from './pipeline-migration/ja-JP';
const jaJP = {
assistant: {
movePosition: 'アシスタントを左右に移動',
resizeHeight: 'アシスタントの高さを調整',
sessions: '会話を切り替え',
currentChat: '現在の会話',
stop: 'タスクを停止',
+2
View File
@@ -1,6 +1,8 @@
import pipelineMigration from './pipeline-migration/ru-RU';
const ruRU = {
assistant: {
movePosition: 'Переместить помощника по горизонтали',
resizeHeight: 'Изменить высоту помощника',
sessions: 'Диалоги',
currentChat: 'Текущий диалог',
stop: 'Остановить задачу',
+2
View File
@@ -1,6 +1,8 @@
import pipelineMigration from './pipeline-migration/th-TH';
const thTH = {
assistant: {
movePosition: 'เลื่อนผู้ช่วยในแนวนอน',
resizeHeight: 'ปรับความสูงของผู้ช่วย',
sessions: 'การสนทนา',
currentChat: 'การสนทนาปัจจุบัน',
stop: 'หยุดงาน',
+2
View File
@@ -1,6 +1,8 @@
import pipelineMigration from './pipeline-migration/vi-VN';
const viVN = {
assistant: {
movePosition: 'Di chuyển trợ lý theo chiều ngang',
resizeHeight: 'Điều chỉnh chiều cao trợ lý',
sessions: 'Cuộc trò chuyện',
currentChat: 'Cuộc trò chuyện hiện tại',
stop: 'Dừng tác vụ',
+2
View File
@@ -1,6 +1,8 @@
import pipelineMigration from './pipeline-migration/zh-Hans';
const zhHans = {
assistant: {
movePosition: '左右移动助手',
resizeHeight: '调整助手高度',
sessions: '切换会话',
currentChat: '当前会话',
stop: '停止任务',
+2
View File
@@ -1,6 +1,8 @@
import pipelineMigration from './pipeline-migration/zh-Hant';
const zhHant = {
assistant: {
movePosition: '左右移動助手',
resizeHeight: '調整助手高度',
sessions: '切換對話',
currentChat: '目前對話',
stop: '停止任務',
+155
View File
@@ -12,6 +12,161 @@ const chat = {
model_name: null,
};
test('resizes from the top handle, keeps height on reopen, and fits small viewports', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAssistant: true,
});
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
json: { code: 0, data: { providers: [{ uuid: 'provider' }] } },
}),
);
await page.route('**/api/v1/assistant/recommended-model', (route) =>
route.fulfill({
json: { code: 0, data: { uuid: 'llm-valid', name: 'Model' } },
}),
);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/home/bots');
const panel = page.getByRole('dialog', { name: 'Workspace assistant' });
const handle = panel.getByRole('separator', {
name: 'Resize assistant height',
});
await expect(handle).toBeVisible();
const initial = (await panel.boundingBox())!;
const grip = (await handle.boundingBox())!;
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
await page.mouse.down();
await page.mouse.move(
grip.x + grip.width / 2,
grip.y + grip.height / 2 - 140,
{ steps: 8 },
);
await page.mouse.up();
await expect
.poll(async () => (await panel.boundingBox())!.height)
.toBeCloseTo(initial.height + 140, 0);
const expanded = (await panel.boundingBox())!;
expect(expanded.y + expanded.height).toBeCloseTo(
initial.y + initial.height,
0,
);
await panel
.getByRole('button', { name: 'Collapse workspace assistant', exact: true })
.click();
const dock = page.getByRole('button', {
name: 'Expand workspace assistant',
exact: true,
});
await expect(dock).toBeVisible();
expect((await dock.boundingBox())!.height).toBe(21);
await dock.click();
await expect
.poll(async () => (await panel.boundingBox())!.height)
.toBeCloseTo(expanded.height, 0);
await page.reload();
await expect(panel).toBeVisible();
await expect
.poll(async () => (await panel.boundingBox())!.height)
.toBeCloseTo(expanded.height, 0);
await handle.focus();
await page.keyboard.press('Home');
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(280);
await page.keyboard.press('ArrowUp');
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(304);
await page.keyboard.press('End');
const preferredHeight = page.viewportSize()!.height - 48;
await expect
.poll(async () => (await panel.boundingBox())!.height)
.toBe(page.viewportSize()!.height - 48);
await page.setViewportSize({ width: 375, height: 580 });
await expect
.poll(async () => (await panel.boundingBox())!.y)
.toBeGreaterThanOrEqual(48);
await expect(panel.getByRole('textbox')).toBeVisible();
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(532);
await page.reload();
await expect(panel).toBeVisible();
await expect.poll(async () => (await panel.boundingBox())!.height).toBe(532);
expect(
await page.evaluate(() =>
localStorage.getItem('langbot-assistant-panel-height'),
),
).toBe(String(preferredHeight));
await page.setViewportSize({ width: 1280, height: 1000 });
await expect
.poll(async () => (await panel.boundingBox())!.height)
.toBe(preferredHeight);
const moveHandle = panel.getByRole('slider', {
name: 'Move assistant horizontally',
});
const beforeMove = (await panel.boundingBox())!;
const title = (await moveHandle.boundingBox())!;
await page.mouse.move(title.x + title.width / 2, title.y + title.height / 2);
await page.mouse.down();
await page.mouse.move(
title.x + title.width / 2 + 180,
title.y + title.height / 2,
{ steps: 8 },
);
await page.mouse.up();
await expect
.poll(async () => (await panel.boundingBox())!.x)
.toBeCloseTo(beforeMove.x + 180, 0);
await page.reload();
await expect(panel).toBeVisible();
await expect
.poll(async () => (await panel.boundingBox())!.x)
.toBeCloseTo(beforeMove.x + 180, 0);
await moveHandle.focus();
await page.keyboard.press('End');
await expect
.poll(async () => {
const box = (await panel.boundingBox())!;
return box.x + box.width;
})
.toBe(1268);
await page.keyboard.press('Home');
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
await page.setViewportSize({ width: 375, height: 580 });
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
expect((await panel.boundingBox())!.width).toBe(351);
await page.reload();
await expect(panel).toBeVisible();
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
await page.setViewportSize({ width: 1440, height: 1000 });
await expect.poll(async () => (await panel.boundingBox())!.x).toBe(12);
await panel
.getByRole('button', { name: 'Collapse workspace assistant', exact: true })
.click();
await expect(dock).toBeVisible();
const dockBox = (await dock.boundingBox())!;
const startX = dockBox.x + dockBox.width / 2;
const startY = dockBox.y + dockBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX + 150, startY, { steps: 8 });
await page.mouse.up();
await expect(dock).toBeVisible();
await expect(panel).toHaveCount(0);
await expect
.poll(async () => (await dock.boundingBox())!.x)
.toBeCloseTo(dockBox.x + 150, 0);
await dock.click();
await expect(panel).toBeVisible();
await expect
.poll(async () => (await panel.boundingBox())!.x)
.toBeCloseTo(162, 0);
await page.reload();
await expect(panel).toBeVisible();
await expect
.poll(async () => (await panel.boundingBox())!.x)
.toBeCloseTo(162, 0);
});
test('sending survives model labels appearing and disappearing in stream snapshots', async ({
page,
}) => {