mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 07:55:18 +08:00
feat(web): persist assistant size and draggable position
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import pipelineMigration from './pipeline-migration/ja-JP';
|
||||
const jaJP = {
|
||||
assistant: {
|
||||
movePosition: 'アシスタントを左右に移動',
|
||||
resizeHeight: 'アシスタントの高さを調整',
|
||||
sessions: '会話を切り替え',
|
||||
currentChat: '現在の会話',
|
||||
stop: 'タスクを停止',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import pipelineMigration from './pipeline-migration/ru-RU';
|
||||
const ruRU = {
|
||||
assistant: {
|
||||
movePosition: 'Переместить помощника по горизонтали',
|
||||
resizeHeight: 'Изменить высоту помощника',
|
||||
sessions: 'Диалоги',
|
||||
currentChat: 'Текущий диалог',
|
||||
stop: 'Остановить задачу',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import pipelineMigration from './pipeline-migration/th-TH';
|
||||
const thTH = {
|
||||
assistant: {
|
||||
movePosition: 'เลื่อนผู้ช่วยในแนวนอน',
|
||||
resizeHeight: 'ปรับความสูงของผู้ช่วย',
|
||||
sessions: 'การสนทนา',
|
||||
currentChat: 'การสนทนาปัจจุบัน',
|
||||
stop: 'หยุดงาน',
|
||||
|
||||
@@ -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ụ',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import pipelineMigration from './pipeline-migration/zh-Hans';
|
||||
const zhHans = {
|
||||
assistant: {
|
||||
movePosition: '左右移动助手',
|
||||
resizeHeight: '调整助手高度',
|
||||
sessions: '切换会话',
|
||||
currentChat: '当前会话',
|
||||
stop: '停止任务',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import pipelineMigration from './pipeline-migration/zh-Hant';
|
||||
const zhHant = {
|
||||
assistant: {
|
||||
movePosition: '左右移動助手',
|
||||
resizeHeight: '調整助手高度',
|
||||
sessions: '切換對話',
|
||||
currentChat: '目前對話',
|
||||
stop: '停止任務',
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user