chore: rename web client to web_v1

This commit is contained in:
2026-07-21 11:24:27 +08:00
parent 465e7bea9b
commit ad6dcd6de4
101 changed files with 24 additions and 5537 deletions

View File

@@ -9,8 +9,7 @@
## 仓库结构 ## 仓库结构
- `backend`: Go Gin + Gorm + PostgreSQL API。 - `backend`: Go Gin + Gorm + PostgreSQL API。
- `apps/senlinai-acro-react`: 当前主 Web 客户端,使用 React + TypeScript + Arco Design。 - `apps/web_v1`: 当前主 Web 客户端,使用 React + TypeScript + Arco Design。
- `apps/web`: 旧 Svelte Web 客户端,已不再作为主前端使用。
- `apps/desktop`: Web 客户端的 Tauri 桌面壳。 - `apps/desktop`: Web 客户端的 Tauri 桌面壳。
- `infra`: 本地开发基础设施,例如 Docker Compose。 - `infra`: 本地开发基础设施,例如 Docker Compose。
- `docs/superpowers/specs`: 产品和技术规格文档。 - `docs/superpowers/specs`: 产品和技术规格文档。
@@ -38,8 +37,7 @@
## 前端规则 ## 前端规则
- 当前主前端使用 React、TypeScript、Vite 和 Arco Design位于 `apps/senlinai-acro-react` - 当前主前端使用 React、TypeScript、Vite 和 Arco Design位于 `apps/web_v1`
- 不要继续在旧 `apps/web` Svelte 应用上开发主前端功能,除非用户明确要求维护旧客户端。
- Web 应用优先面向桌面端构建,同时保持移动浏览器可用。 - Web 应用优先面向桌面端构建,同时保持移动浏览器可用。
- 登录界面必须允许用户输入服务器 IP 地址或域名。 - 登录界面必须允许用户输入服务器 IP 地址或域名。
- 页面级工作台状态使用浏览器式标签页。 - 页面级工作台状态使用浏览器式标签页。
@@ -68,6 +66,6 @@
- 后端变更应在 `backend` 目录运行 `go test ./...` - 后端变更应在 `backend` 目录运行 `go test ./...`
- PostgreSQL 集成检查应从 `DATABASE_URL` 读取实时测试数据库 URL不要提交真实凭据。 - PostgreSQL 集成检查应从 `DATABASE_URL` 读取实时测试数据库 URL不要提交真实凭据。
- Web 变更应在 `apps/senlinai-acro-react` 目录运行 `node scripts/visual-check.mjs``npm run build``npm run lint` - Web 变更应在 `apps/web_v1` 目录运行 `node scripts/visual-check.mjs``npm run build``npm run lint`
- Tauri 变更应先构建 Web 应用,然后在 `apps/desktop` 目录运行 Tauri 验证命令。 - Tauri 变更应先构建 Web 应用,然后在 `apps/desktop` 目录运行 Tauri 验证命令。
- 每个实施任务结束时都应创建一个聚焦的提交。 - 每个实施任务结束时都应创建一个聚焦的提交。

View File

@@ -20,7 +20,7 @@ go run ./cmd/api
启动 Web 客户端: 启动 Web 客户端:
```powershell ```powershell
Set-Location apps/senlinai-acro-react Set-Location apps/web_v1
npm run dev npm run dev
``` ```
@@ -52,7 +52,7 @@ backend/etc/agent.dev.yaml
## 前端 ## 前端
Web 客户端使用 Svelte 和 TypeScript。登录界面允许用户输入服务器 IP 地址或域名,并将其保存为 API base URL。 Web 客户端使用 React、TypeScript、Vite 和 Arco Design。登录界面允许用户输入服务器 IP 地址或域名,并将其保存为 API base URL。
## 桌面端 ## 桌面端
@@ -84,7 +84,7 @@ go test ./... -v
Web Web
```powershell ```powershell
Set-Location apps/senlinai-acro-react Set-Location apps/web_v1
node scripts/visual-check.mjs node scripts/visual-check.mjs
npm run build npm run build
npm run lint npm run lint

View File

@@ -4,10 +4,10 @@
"version": "0.1.0", "version": "0.1.0",
"identifier": "ai.senlin.workbench", "identifier": "ai.senlin.workbench",
"build": { "build": {
"frontendDist": "../../senlinai-acro-react/dist", "frontendDist": "../../web_v1/dist",
"devUrl": "http://localhost:5173", "devUrl": "http://localhost:5173",
"beforeDevCommand": "cd ../senlinai-acro-react && npm run dev", "beforeDevCommand": "cd ../web_v1 && npm run dev",
"beforeBuildCommand": "cd ../senlinai-acro-react && npm run build" "beforeBuildCommand": "cd ../web_v1 && npm run build"
}, },
"app": { "app": {
"windows": [ "windows": [

24
apps/web/.gitignore vendored
View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,8 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

View File

@@ -1,14 +0,0 @@
# SenlinAI Web
Svelte + TypeScript web client for the project workbench.
## Scripts
```powershell
npm run dev
npm test -- --run
npm run build
npx playwright test
```
The login shell lets users enter the server IP address or domain name used as the API base URL.

View File

@@ -1,79 +0,0 @@
import { expect, test } from '@playwright/test';
test('login and project channel workbench flow', async ({ page }) => {
await page.goto('/');
await expect(page.getByLabel('Server IP or domain')).toBeVisible();
await page.getByLabel('Server IP or domain').fill('localhost:8080');
await page.getByLabel('Email or username').fill('david@example.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByLabel('Project list')).toBeVisible();
await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible();
await page.getByRole('button', { name: 'Work Plan 8' }).click();
await expect(page.getByText('Tasks')).toBeVisible();
await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible();
await page.getByRole('button', { name: 'Project A2' }).click();
await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByText('Ops Dashboard')).toBeVisible();
await page.getByRole('button', { name: 'Ops Dashboard' }).click();
await expect(page.getByText('Open external channel')).toBeVisible();
});
for (const server of [
'https://user:secret@example.com',
'http:////example.com',
'http//example.com',
'https//example.com',
'http:/example.com',
'https:/example.com',
'http://example.com\\path',
'http://example..com',
'http://-bad.com',
]) {
test(`rejects malformed server address: ${server}`, async ({ page }) => {
await page.goto('/');
await page.getByLabel('Server IP or domain').fill(server);
await page.getByLabel('Email or username').fill('david@example.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByText('Enter a valid HTTP or HTTPS server address.')).toBeVisible();
await expect(page.getByLabel('Server IP or domain')).toBeVisible();
});
}
test('mobile workbench keeps project navigation and inspector compact', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
await page.getByLabel('Server IP or domain').fill('localhost:8080');
await page.getByLabel('Email or username').fill('david@example.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Log in' }).click();
const rail = page.getByLabel('Project list');
const sidebar = page.getByLabel('Project channels');
const stage = page.getByLabel('Channel content');
const inspector = page.getByLabel('Object inspector');
await expect(rail).toHaveCSS('grid-auto-flow', 'column');
await expect(rail).toHaveCSS('max-height', '76px');
await expect(inspector).toHaveCSS('max-height', '288px');
const workPlan = page.getByRole('button', { name: 'Work Plan 8' });
await expect(workPlan).toBeVisible();
await expect(workPlan).toBeInViewport();
await workPlan.click();
await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible();
const [sidebarBox, stageBox] = await Promise.all([sidebar.boundingBox(), stage.boundingBox()]);
expect(sidebarBox?.y).toBeLessThan(stageBox?.y ?? 0);
expect((stageBox?.y ?? 0) - (sidebarBox?.y ?? 0)).toBeLessThanOrEqual(248);
});

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>项目工作台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +0,0 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest",
"preview": "vite preview"
},
"dependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"svelte": "^5.42.0"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.2.9",
"@tsconfig/svelte": "^5.0.5",
"jsdom": "^27.3.0",
"typescript": "~6.0.2",
"vite": "^7.2.7",
"vitest": "^4.0.16"
}
}

View File

@@ -1,20 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
webServer: {
command: 'npm run dev -- --host 127.0.0.1',
url: 'http://127.0.0.1:5173',
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://127.0.0.1:5173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -1,19 +0,0 @@
<script lang="ts">
import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte';
import ServerLogin from '../features/auth/ServerLogin.svelte';
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
let currentUser: { account: string } | null = null;
function handleLogin(detail: { apiBase: string; account: string }) {
apiBase = detail.apiBase;
localStorage.setItem('apiBase', apiBase);
currentUser = { account: detail.account };
}
</script>
{#if currentUser}
<ProjectWorkbench {currentUser} />
{:else}
<ServerLogin {apiBase} onLogin={handleLogin} />
{/if}

View File

@@ -1,10 +0,0 @@
export type WorkspaceTab = {
key: string;
title: string;
route: string;
};
export function openTab(existing: WorkspaceTab[], next: WorkspaceTab): WorkspaceTab[] {
if (existing.some((tab) => tab.key === next.key)) return existing;
return [...existing, next];
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,18 +0,0 @@
<script lang="ts">
export type AISessionCard = {
id: number;
title: string;
updatedAt: string;
};
export let sessions: AISessionCard[] = [];
</script>
<section aria-label="AI 会话" class="ai-session-grid">
{#each sessions as session}
<button class="ai-session-card" type="button">
<strong>{session.title}</strong>
<span>{session.updatedAt}</span>
</button>
{/each}
</section>

View File

@@ -1,155 +0,0 @@
<script lang="ts">
export let apiBase: string;
export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {};
let server = apiBase;
let account = '';
let password = '';
let error = '';
let connectionStatus = 'Not connected. Enter a server address to continue.';
function normalizeServer(value: string): { apiBase?: string; error?: string } {
if (!value.trim()) {
return { error: 'Enter a server address.' };
}
if (hasWhitespace(value) || value.includes('\\') || hasMalformedHttpProtocol(value) || hasUnsupportedProtocol(value)) {
return { error: 'Enter a valid HTTP or HTTPS server address.' };
}
const candidate = /^https?:\/\//i.test(value) ? value : `http://${value}`;
if (!/^https?:\/\/(?![\\/])/i.test(candidate) || hasCredentials(candidate)) {
return { error: 'Enter a valid HTTP or HTTPS server address.' };
}
try {
const parsed = new URL(candidate);
if (
!parsed.hostname ||
parsed.username ||
parsed.password ||
!['http:', 'https:'].includes(parsed.protocol) ||
!isValidHostname(parsed.hostname)
) {
return { error: 'Enter a valid HTTP or HTTPS server address.' };
}
return { apiBase: candidate };
} catch {
return { error: 'Enter a valid HTTP or HTTPS server address.' };
}
}
function hasWhitespace(value: string): boolean {
if (/\s/u.test(value)) return true;
try {
return /\s/u.test(decodeURIComponent(value));
} catch {
return true;
}
}
function hasUnsupportedProtocol(value: string): boolean {
const explicitProtocol = value.match(/^([a-z][a-z\d+.-]*):\/\//i);
if (explicitProtocol) {
return !['http', 'https'].includes(explicitProtocol[1].toLowerCase());
}
return /^[a-z][a-z\d+.-]*:(?!\d)/i.test(value);
}
function hasMalformedHttpProtocol(value: string): boolean {
return /^https?:\/(?!\/)/i.test(value) || /^https?\/{2}(?!\/)/i.test(value);
}
function hasCredentials(value: string): boolean {
const authority = value.replace(/^https?:\/\//i, '').split(/[/?#]/, 1)[0];
return authority.includes('@');
}
function isValidHostname(hostname: string): boolean {
if (hostname === 'localhost') return true;
if (/^\[[\da-f:.]+\]$/i.test(hostname)) return true;
if (/^\d+(?:\.\d+){3}$/.test(hostname)) {
return hostname.split('.').every((segment) => Number(segment) <= 255);
}
return hostname.split('.').every((label) => /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i.test(label));
}
function submitLogin() {
error = '';
const normalized = normalizeServer(server);
if (normalized.error) {
error = normalized.error;
connectionStatus = 'Check the server address before signing in.';
return;
}
if (!account.trim() || !password.trim()) {
error = 'Enter an account and password.';
return;
}
server = normalized.apiBase ?? server;
connectionStatus = 'Connection settings are ready.';
onLogin({ apiBase: server, account: account.trim() });
}
</script>
<main class="login-page">
<section class="login-panel" aria-labelledby="login-title">
<div class="login-brand-panel">
<div class="login-brand" aria-hidden="true">S</div>
<h1>SenlinAI</h1>
<p>私有部署 · 安全可控的智能协作平台</p>
<small>知识沉淀 · 团队协作 · AI 助理</small>
<nav class="login-links" aria-label="Login resources">
<a href="/" on:click|preventDefault>隐私政策</a>
<a href="/" on:click|preventDefault>服务协议</a>
<a href="/" on:click|preventDefault>关于 SenlinAI</a>
</nav>
</div>
<form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}>
<div class="login-heading">
<p>登录到您的 SenlinAI</p>
<h2 id="login-title">连接私有工作台</h2>
</div>
<label for="server-address">Server IP or domain</label>
<input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
<label for="login-account">Email or username</label>
<input id="login-account" name="account" bind:value={account} autocomplete="username" />
<label for="login-password">Password</label>
<input
id="login-password"
name="password"
type="password"
bind:value={password}
autocomplete="current-password"
/>
{#if error}
<p class="form-error" aria-live="polite">{error}</p>
{/if}
<p class="connection-status" role="status">{connectionStatus}</p>
<button type="submit">Log in</button>
<p class="login-note">The server address is remembered on this device.</p>
</form>
<aside class="login-value-panel" aria-label="Product values">
<h2>安全 · 专注 · 高效</h2>
<p>SenlinAI 是面向知识工作者与内部团队的私有部署工作台,帮助团队在统一空间中收集信息、整理知识、协同决策。</p>
<ul>
<li><strong>私有部署,数据自有</strong><span>所有数据与模型运行在您的环境中。</span></li>
<li><strong>知识沉淀,结构清晰</strong><span>从收集到整理,形成可复用的团队知识。</span></li>
<li><strong>协同高效,聚焦成果</strong><span>围绕项目与任务协同,减少沟通成本。</span></li>
</ul>
</aside>
</section>
</main>

View File

@@ -1,102 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { describe, expect, it, vi } from 'vitest';
import ServerLogin from './ServerLogin.svelte';
describe('ServerLogin', () => {
it('renders server, account, and password fields', () => {
render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Not connected. Enter a server address to continue.');
});
it('normalizes server address and emits login details', async () => {
const onLogin = vi.fn();
render(ServerLogin, {
props: {
apiBase: 'localhost:8080',
onLogin,
},
});
await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
target: { value: '10.0.0.12:8080' },
});
await fireEvent.input(screen.getByLabelText('Email or username'), {
target: { value: 'david@example.com' },
});
await fireEvent.input(screen.getByLabelText('Password'), {
target: { value: 'secret' },
});
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(onLogin).toHaveBeenCalledWith({
apiBase: 'http://10.0.0.12:8080',
account: 'david@example.com',
});
});
it.each([
['localhost', 'http://localhost'],
['localhost:8080', 'http://localhost:8080'],
['192.168.1.20:3000', 'http://192.168.1.20:3000'],
['example.com:8443', 'http://example.com:8443'],
['https://example.com', 'https://example.com'],
])('accepts and normalizes a valid server value: %s', async (server, apiBase) => {
const onLogin = vi.fn();
render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(onLogin).toHaveBeenCalledWith({ apiBase, account: 'david@example.com' });
});
it('shows an error when account or password is missing', async () => {
render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
});
it.each([
['', 'Enter a server address.'],
['http:// bad-server', 'Enter a valid HTTP or HTTPS server address.'],
[' https://example.com', 'Enter a valid HTTP or HTTPS server address.'],
['https://example.com/path with spaces', 'Enter a valid HTTP or HTTPS server address.'],
['https://example%20.com', 'Enter a valid HTTP or HTTPS server address.'],
['https://user:secret@example.com', 'Enter a valid HTTP or HTTPS server address.'],
['http://', 'Enter a valid HTTP or HTTPS server address.'],
['ftp://example.com', 'Enter a valid HTTP or HTTPS server address.'],
['http:////example.com', 'Enter a valid HTTP or HTTPS server address.'],
['http//example.com', 'Enter a valid HTTP or HTTPS server address.'],
['https//example.com', 'Enter a valid HTTP or HTTPS server address.'],
['http:/example.com', 'Enter a valid HTTP or HTTPS server address.'],
['https:/example.com', 'Enter a valid HTTP or HTTPS server address.'],
['http://example.com\\path', 'Enter a valid HTTP or HTTPS server address.'],
['example.com\\path', 'Enter a valid HTTP or HTTPS server address.'],
['http://example..com', 'Enter a valid HTTP or HTTPS server address.'],
['http://-bad.com', 'Enter a valid HTTP or HTTPS server address.'],
['http://bad-.com', 'Enter a valid HTTP or HTTPS server address.'],
['http://bad_host.com', 'Enter a valid HTTP or HTTPS server address.'],
['http://bad!.com', 'Enter a valid HTTP or HTTPS server address.'],
])('rejects an invalid server value: %s', async (server, error) => {
const onLogin = vi.fn();
render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(screen.getByText(error)).toBeInTheDocument();
expect(onLogin).not.toHaveBeenCalled();
});
});

View File

@@ -1,32 +0,0 @@
<script lang="ts">
export type ProjectOption = {
id: number;
name: string;
};
export let projects: ProjectOption[] = [];
export let activeProjectID: number | undefined = undefined;
export let onSubmit: (input: { projectID: number; body: string }) => void = () => {};
let projectID = activeProjectID ?? projects[0]?.id ?? 0;
let body = '';
$: if (activeProjectID && projectID !== activeProjectID) {
projectID = activeProjectID;
}
function submit() {
onSubmit({ projectID, body });
body = '';
}
</script>
<form aria-label="快速收集" class="quick-capture" on:submit|preventDefault={submit}>
<select name="projectID" aria-label="目标项目" bind:value={projectID}>
{#each projects as project}
<option value={project.id}>{project.name}</option>
{/each}
</select>
<textarea name="body" aria-label="收集内容" bind:value={body}></textarea>
<button type="submit">保存</button>
</form>

View File

@@ -1,24 +0,0 @@
<script lang="ts">
export let onCapture: (input: { sourceType: string; body: string }) => void = () => {};
let body = '';
let sourceType = 'text';
function submit() {
onCapture({ sourceType, body });
body = '';
}
</script>
<section aria-label="项目 Inbox" class="inbox-panel">
<select aria-label="收集类型" bind:value={sourceType}>
<option value="text">文本</option>
<option value="link">链接</option>
<option value="file">文件</option>
</select>
<textarea aria-label="收集内容" bind:value={body}></textarea>
<div class="toolbar">
<button type="button" on:click={submit}>保存到 Inbox</button>
<button type="button">分析/整理</button>
</div>
</section>

View File

@@ -1,37 +0,0 @@
<script lang="ts">
import type { Suggestion } from './types';
export let suggestions: Suggestion[] = [];
export let onConfirm: (selected: Suggestion[]) => void;
let selectedIndexes: number[] = [];
function toggle(index: number) {
selectedIndexes = selectedIndexes.includes(index)
? selectedIndexes.filter((item) => item !== index)
: [...selectedIndexes, index];
}
function confirm() {
onConfirm(selectedIndexes.map((index) => suggestions[index]));
}
</script>
<section aria-label="AI 整理建议" class="suggestions">
{#each suggestions as suggestion, index}
<label class="suggestion">
<input
aria-label={`选择 ${suggestion.title}`}
type="checkbox"
checked={selectedIndexes.includes(index)}
on:change={() => toggle(index)}
/>
<span class="suggestion-body">
<strong>{suggestion.title}</strong>
<small>{suggestion.kind}</small>
<span>{suggestion.body}</span>
</span>
</label>
{/each}
<button type="button" on:click={confirm}>创建选中项</button>
</section>

View File

@@ -1,25 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { describe, expect, it, vi } from 'vitest';
import SuggestionList from './SuggestionList.svelte';
describe('SuggestionList', () => {
it('requires explicit selection before confirmation', async () => {
const onConfirm = vi.fn();
render(SuggestionList, {
props: {
suggestions: [{ kind: 'task', title: '跟进报价', body: '联系客户' }],
onConfirm,
},
});
await fireEvent.click(screen.getByText('创建选中项'));
expect(onConfirm).toHaveBeenCalledWith([]);
await fireEvent.click(screen.getByLabelText('选择 跟进报价'));
await fireEvent.click(screen.getByText('创建选中项'));
expect(onConfirm).toHaveBeenLastCalledWith([
{ kind: 'task', title: '跟进报价', body: '联系客户' },
]);
});
});

View File

@@ -1,5 +0,0 @@
export type Suggestion = {
kind: 'task' | 'note' | 'source';
title: string;
body: string;
};

View File

@@ -1,12 +0,0 @@
<script lang="ts">
export let value = '';
export let onChange: (value: string) => void = () => {};
</script>
<textarea
aria-label="Markdown 编辑器"
class="markdown-editor"
spellcheck="false"
bind:value
on:input={() => onChange(value)}
></textarea>

View File

@@ -1,21 +0,0 @@
<script lang="ts">
import type { ProjectDashboardSummary } from '../../lib/api';
export let summary: ProjectDashboardSummary;
$: metrics = [
['待处理 Inbox', summary.pending_inbox_count],
['当前任务', summary.open_task_count],
['笔记资料', summary.recent_note_count],
['AI 会话', summary.recent_session_count],
] as const;
</script>
<section aria-label="项目总览" class="dashboard-grid">
{#each metrics as [label, value]}
<article class="dashboard-metric">
<span>{label}</span>
<strong>{value}</strong>
</article>
{/each}
</section>

View File

@@ -1,25 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import ProjectDashboard from './ProjectDashboard.svelte';
describe('ProjectDashboard', () => {
it('shows project counters', () => {
render(ProjectDashboard, {
props: {
summary: {
project_id: 1,
pending_inbox_count: 2,
open_task_count: 5,
recent_note_count: 3,
recent_session_count: 4,
},
},
});
expect(screen.getByText('待处理 Inbox')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('当前任务')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
});
});

View File

@@ -1,22 +0,0 @@
<script lang="ts">
export type TaskListItem = {
id: number;
title: string;
status: 'open' | 'doing' | 'done';
assignee?: string;
};
export let tasks: TaskListItem[] = [];
</script>
<section aria-label="任务列表" class="task-list">
{#each tasks as task}
<article class="task-row">
<strong>{task.title}</strong>
<span>{task.status}</span>
{#if task.assignee}
<span>{task.assignee}</span>
{/if}
</article>
{/each}
</section>

View File

@@ -1,32 +0,0 @@
<script lang="ts">
import type { InspectorItem, ProjectWorkspace, WorkTask, WorkbenchChannel } from './types';
import OverviewChannel from './channels/OverviewChannel.svelte';
import InboxChannel from './channels/InboxChannel.svelte';
import TasksChannel from './channels/TasksChannel.svelte';
import AISessionsChannel from './channels/AISessionsChannel.svelte';
import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte';
import CronChannel from './channels/CronChannel.svelte';
import CustomLinkChannel from './channels/CustomLinkChannel.svelte';
export let workspace: ProjectWorkspace;
export let tasks: WorkTask[] = workspace.tasks;
export let channel: WorkbenchChannel;
export let onInspect: (item: InspectorItem) => void;
export let onToggleTask: (taskID: string) => void = () => {};
</script>
{#if channel.type === 'overview'}
<OverviewChannel {workspace} {onInspect} />
{:else if channel.type === 'inbox'}
<InboxChannel messages={workspace.inbox} {onInspect} />
{:else if channel.type === 'tasks'}
<TasksChannel {tasks} {onInspect} onToggleTask={onToggleTask} />
{:else if channel.type === 'ai_sessions'}
<AISessionsChannel sessions={workspace.aiSessions} {onInspect} />
{:else if channel.type === 'notes_sources'}
<NotesSourcesChannel items={workspace.notesSources} {onInspect} />
{:else if channel.type === 'cron'}
<CronChannel plans={workspace.cronPlans} {onInspect} />
{:else}
<CustomLinkChannel {channel} />
{/if}

View File

@@ -1,64 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen, within } from '@testing-library/svelte';
import { describe, expect, it, vi } from 'vitest';
import ChannelContent from './ChannelContent.svelte';
import { getProjectWorkspace } from './mockData';
describe('ChannelContent', () => {
const workspace = getProjectWorkspace(1);
it('renders a distinctive heading or control for every channel type', () => {
const expectedContent = {
overview: { role: 'heading', name: 'Overview' },
inbox: { role: 'heading', name: 'Inbox' },
tasks: { role: 'checkbox', name: 'Mark Confirm homepage information architecture complete' },
ai_sessions: { role: 'heading', name: 'AI Sessions' },
notes_sources: { role: 'heading', name: 'Notes & Sources' },
cron: { role: 'heading', name: 'Cron Plans' },
custom_link: { role: 'link', name: 'Open external channel' },
} as const;
for (const type of Object.keys(expectedContent) as Array<keyof typeof expectedContent>) {
const channel = workspace.channels.find((item) => item.type === type);
if (!channel) throw new Error(`missing ${type}`);
const { unmount } = render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
expect(screen.getByRole(expectedContent[type].role, { name: expectedContent[type].name })).toBeInTheDocument();
unmount();
}
});
it('sends selected task details to inspector', async () => {
let inspectedTitle = '';
const channel = workspace.channels.find((item) => item.type === 'tasks');
if (!channel) throw new Error('missing task channel');
render(ChannelContent, {
props: {
workspace,
channel,
onInspect: (item) => {
inspectedTitle = item.title;
},
},
});
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
expect(inspectedTitle).toBe('Confirm homepage information architecture');
});
it('reports task completion changes to its owner', async () => {
const channel = workspace.channels.find((item) => item.type === 'tasks');
if (!channel) throw new Error('missing task channel');
const onToggleTask = vi.fn();
render(ChannelContent, { props: { workspace, channel, onInspect: () => {}, onToggleTask } });
const completion = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
expect(completion).not.toBeChecked();
await fireEvent.click(completion);
expect(onToggleTask).toHaveBeenCalledWith('1-task-1');
});
});

View File

@@ -1,67 +0,0 @@
<script lang="ts">
import type { InspectorItem } from './types';
export let item: InspectorItem | null;
let activeTab: 'discussion' | 'properties' | 'more' = 'discussion';
let copyFeedback = '';
let copyFailed = false;
let currentItem: InspectorItem | null = null;
$: if (item !== currentItem) {
currentItem = item;
copyFeedback = '';
copyFailed = false;
}
async function copyLink() {
const link = new URL(`?item=${encodeURIComponent(item?.title ?? '')}`, window.location.href).toString();
if (!navigator.clipboard?.writeText) {
copyFailed = true;
copyFeedback = 'Clipboard access is unavailable.';
return;
}
try {
await navigator.clipboard.writeText(link);
copyFailed = false;
copyFeedback = 'Link copied to clipboard.';
} catch {
copyFailed = true;
copyFeedback = 'Unable to copy link.';
}
}
</script>
<aside class="object-inspector" aria-label="Object inspector">
<div class="inspector-tabs">
<button type="button" aria-pressed={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button>
<button type="button" aria-pressed={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button>
<button type="button" aria-pressed={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button>
</div>
{#if item}
<h2>{item.title}</h2>
<p>{item.description}</p>
{#if activeTab === 'discussion'}
<p>No comments yet. Add project discussion here later.</p>
{:else if activeTab === 'properties'}
<dl>
{#each item.properties as property}
<div>
<dt>{property.label}</dt>
<dd>{property.value}</dd>
</div>
{/each}
</dl>
{:else}
<button type="button" on:click={copyLink}>Copy link</button>
{#if copyFeedback}
<p class:copy-error={copyFailed} class="copy-feedback" role="status">{copyFeedback}</p>
{/if}
<button type="button" aria-label="Archive is unavailable in this preview" title="Archive is unavailable in this preview" disabled>Archive</button>
{/if}
{:else}
<h2>Inspector</h2>
<p>Select an item to inspect discussion, properties, and actions.</p>
{/if}
</aside>

View File

@@ -1,73 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import ObjectInspector from './ObjectInspector.svelte';
const item = {
title: 'Review navigation',
type: 'Task',
description: 'Check the project navigation flow.',
properties: [],
};
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
function setClipboard(writeText: Clipboard['writeText']) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText } as Clipboard,
});
}
afterEach(() => {
cleanup();
if (originalClipboard) {
Object.defineProperty(navigator, 'clipboard', originalClipboard);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
});
describe('ObjectInspector', () => {
async function openMoreActions() {
await fireEvent.click(screen.getByRole('button', { name: 'More' }));
}
it('shows copy success only after clipboard writing resolves', async () => {
let resolveWrite: (() => void) | undefined;
const writeText = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveWrite = resolve;
}),
);
setClipboard(writeText);
render(ObjectInspector, { props: { item } });
await openMoreActions();
await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
expect(screen.queryByRole('status')).not.toBeInTheDocument();
resolveWrite?.();
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Link copied to clipboard.'));
});
it('reports a copy failure when clipboard writing rejects', async () => {
setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied')));
render(ObjectInspector, { props: { item } });
await openMoreActions();
await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Unable to copy link.'));
});
it('reports unavailable clipboard access without showing success', async () => {
Reflect.deleteProperty(navigator, 'clipboard');
render(ObjectInspector, { props: { item } });
await openMoreActions();
await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
expect(screen.getByRole('status')).toHaveTextContent('Clipboard access is unavailable.');
});
});

View File

@@ -1,67 +0,0 @@
<script lang="ts">
import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types';
export let project: WorkbenchProject;
export let channels: WorkbenchChannel[];
export let selectedChannelID: string;
export let tags: string[];
export let recentSessions: AISessionItem[];
export let onSelectChannel: (channelID: string) => void;
const channelIcons: Record<WorkbenchChannel['icon'], string> = {
home: '\u2302',
mail: '\u2709',
list: '\u2630',
sparkles: '\u2726',
file: '\u25A4',
clock: '\u25F7',
link: '\u21AA',
};
$: sortedChannels = [...channels].sort((left, right) => left.sortOrder - right.sortOrder);
</script>
<aside class="channel-sidebar" aria-label="Project channels">
<header>
<div>
<p>Current project</p>
<h2>{project.name}</h2>
</div>
<button aria-label="Project settings" disabled>&#9881;</button>
</header>
<ul class="tag-row" aria-label="Project tags">
{#each tags as tag}
<li>#{tag}</li>
{/each}
</ul>
<section class="channel-group">
{#each sortedChannels as channel}
<button
class:active={channel.id === selectedChannelID}
aria-pressed={channel.id === selectedChannelID}
aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`}
on:click={() => onSelectChannel(channel.id)}
>
<span class="channel-icon" aria-hidden="true">{channelIcons[channel.icon]}</span>
<span>{channel.title}</span>
{#if channel.count !== undefined}
<small>{channel.count}</small>
{/if}
</button>
{/each}
</section>
<section class="recent-sessions" aria-label="Recent sessions">
<h3>Recent sessions</h3>
<ul>
{#each recentSessions as session}
<li>
<span>{session.title}</span>
<small>{session.updatedAt}</small>
</li>
{/each}
</ul>
</section>
</aside>

View File

@@ -1,26 +0,0 @@
<script lang="ts">
import type { WorkbenchProject } from './types';
export let projects: WorkbenchProject[];
export let selectedProjectID: number;
export let onSelectProject: (projectID: number) => void;
</script>
<nav class="project-rail" aria-label="Project list">
<button class="rail-logo" aria-label="Dashboard" disabled>SA</button>
{#each projects as project}
<button
class:active={project.id === selectedProjectID}
aria-pressed={project.id === selectedProjectID}
aria-label={project.name}
title={project.name}
on:click={() => onSelectProject(project.id)}
>
<span class="project-initials">{project.initials}</span>
{#if project.unreadCount}
<small class:urgent={project.unreadCount > 20} aria-label={`${project.name} updates ${project.unreadCount}`}>{project.unreadCount}</small>
{/if}
</button>
{/each}
<button class="add-project" aria-label="Create project" disabled>+</button>
</nav>

View File

@@ -1,68 +0,0 @@
<script lang="ts">
import { getProjectWorkspace, workbenchProjects } from './mockData';
import ChannelContent from './ChannelContent.svelte';
import ObjectInspector from './ObjectInspector.svelte';
import ProjectRail from './ProjectRail.svelte';
import ProjectChannelSidebar from './ProjectChannelSidebar.svelte';
import WorkspaceTopbar from './WorkspaceTopbar.svelte';
import WorkspaceStatusBar from './WorkspaceStatusBar.svelte';
import type { InspectorItem, WorkTask } from './types';
export let currentUser: { account: string };
let theme: 'light' | 'dark' = 'light';
let selectedProjectID = workbenchProjects[0].id;
let selectedItem: InspectorItem | null = null;
let tasksByProjectID: Record<number, WorkTask[]> = Object.fromEntries(
workbenchProjects.map((project) => [project.id, getProjectWorkspace(project.id).tasks.map((task) => ({ ...task }))]),
);
$: workspace = getProjectWorkspace(selectedProjectID);
$: tasks = tasksByProjectID[selectedProjectID] ?? workspace.tasks;
$: selectedChannelID = workspace.channels[0].id;
$: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0];
function selectProject(projectID: number) {
selectedItem = null;
selectedProjectID = projectID;
}
function selectChannel(channelID: string) {
selectedItem = null;
selectedChannelID = channelID;
}
function inspectItem(item: InspectorItem) {
selectedItem = item;
}
function toggleTask(taskID: string) {
tasksByProjectID = {
...tasksByProjectID,
[selectedProjectID]: tasks.map((task) => task.id === taskID ? { ...task, completed: !task.completed } : task),
};
}
function toggleTheme() {
theme = theme === 'light' ? 'dark' : 'light';
}
</script>
<main class="workbench-shell" aria-label="SenlinAI workbench" data-theme={theme}>
<WorkspaceTopbar {theme} onToggleTheme={toggleTheme} />
<div class="workbench-body">
<ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} />
<ProjectChannelSidebar
project={workspace.project}
channels={workspace.channels}
{selectedChannelID}
tags={workspace.tags}
recentSessions={workspace.recentSessions}
onSelectChannel={selectChannel}
/>
<section class="channel-stage" aria-label="Channel content">
<ChannelContent {workspace} {tasks} channel={selectedChannel} onInspect={inspectItem} onToggleTask={toggleTask} />
</section>
<ObjectInspector item={selectedItem} />
</div>
<WorkspaceStatusBar account={currentUser.account} />
</main>

View File

@@ -1,152 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
import { describe, expect, it, vi } from 'vitest';
import ProjectWorkbench from './ProjectWorkbench.svelte';
describe('ProjectWorkbench', () => {
it('renders project rail, channel sidebar, topbar, and inspector', () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
expect(screen.getByLabelText('Project list')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByLabelText('Project A1 updates 36')).toBeInTheDocument();
expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
expect(screen.getByLabelText('Global search')).toBeEnabled();
expect(screen.getByRole('button', { name: 'Search workspace' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Switch to dark mode' })).toBeInTheDocument();
expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
});
it('pins user actions and task status to the page bottom bar', () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
const statusBar = screen.getByLabelText('Workspace status');
expect(within(statusBar).getByText('david@example.com')).toBeInTheDocument();
expect(within(statusBar).getByRole('button', { name: 'Friends' })).toBeInTheDocument();
expect(within(statusBar).getByRole('button', { name: 'Direct messages' })).toBeInTheDocument();
expect(within(statusBar).getByRole('button', { name: 'Settings' })).toBeInTheDocument();
expect(within(statusBar).getByText('Sync complete')).toBeInTheDocument();
expect(within(statusBar).getByText('3 tasks running')).toBeInTheDocument();
expect(within(statusBar).getByText('AI queue idle')).toBeInTheDocument();
expect(within(statusBar).getByText('Draft saved')).toBeInTheDocument();
});
it('shows overview work queues below the metric cards', () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
expect(screen.getByRole('heading', { name: 'Inbox 待处理' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '进行中的任务' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'AI 会话' })).toBeInTheDocument();
expect(screen.getByText('Collect competitor pricing notes')).toBeInTheDocument();
expect(screen.getByText('Confirm homepage information architecture')).toBeInTheDocument();
});
it('toggles between light and dark workbench palettes', async () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
const shell = screen.getByLabelText('SenlinAI workbench');
expect(shell).toHaveAttribute('data-theme', 'light');
await fireEvent.click(screen.getByRole('button', { name: 'Switch to dark mode' }));
expect(shell).toHaveAttribute('data-theme', 'dark');
expect(screen.getByRole('button', { name: 'Switch to light mode' })).toBeInTheDocument();
});
it('refreshes channels when switching projects', async () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
});
it('marks active channel with aria-pressed', async () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
});
it('shows inspected task details in the inspector', async () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
const inspector = screen.getByLabelText('Object inspector');
expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
await fireEvent.click(within(inspector).getByRole('button', { name: 'Properties' }));
expect(within(inspector).getByRole('button', { name: 'Properties' })).toHaveAttribute('aria-pressed', 'true');
expect(within(inspector).getByText('Status')).toBeInTheDocument();
expect(within(inspector).getByText('Open')).toBeInTheDocument();
expect(within(inspector).getByText('Owner')).toBeInTheDocument();
expect(within(inspector).getByText('David')).toBeInTheDocument();
});
it('clears the inspector when the project or channel changes', async () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
const inspector = screen.getByLabelText('Object inspector');
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
expect(within(inspector).queryByRole('heading', { name: 'Confirm homepage information architecture' })).not.toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
});
it('preserves task completion when switching channels within a project', async () => {
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
const task = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
await fireEvent.click(task);
expect(task).toBeChecked();
await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).toBeChecked();
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).not.toBeChecked();
});
it('provides Copy link feedback and disables unavailable archive actions', async () => {
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) } as Clipboard,
});
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
const inspector = screen.getByLabelText('Object inspector');
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
await fireEvent.click(within(inspector).getByRole('button', { name: 'More' }));
await fireEvent.click(within(inspector).getByRole('button', { name: 'Copy link' }));
await waitFor(() => expect(within(inspector).getByRole('status')).toHaveTextContent('Link copied to clipboard.'));
expect(within(inspector).getByRole('button', { name: 'Archive is unavailable in this preview' })).toBeDisabled();
if (originalClipboard) {
Object.defineProperty(navigator, 'clipboard', originalClipboard);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
});
});

View File

@@ -1,30 +0,0 @@
<script lang="ts">
export let account: string;
$: userInitials = account.slice(0, 2).toUpperCase();
</script>
<footer class="workspace-statusbar" aria-label="Workspace status">
<section class="statusbar-user" aria-label="Current user">
<span class="user-avatar" aria-hidden="true">{userInitials}</span>
<span class="user-meta">
<strong>{account}</strong>
<small>在线</small>
</span>
<button type="button" aria-label="Friends">好友</button>
<button type="button" aria-label="Direct messages">私信</button>
<button type="button" aria-label="Settings">设置</button>
</section>
<section class="statusbar-tasks" aria-label="Task status">
<span>Sync complete</span>
<span>3 tasks running</span>
<span>AI queue idle</span>
<span>Draft saved</span>
</section>
<section class="statusbar-system" aria-label="System status">
<span>本地存储 152GB 可用</span>
<span>系统健康 正常</span>
</section>
</footer>

View File

@@ -1,25 +0,0 @@
<script lang="ts">
export let theme: 'light' | 'dark' = 'light';
export let onToggleTheme: () => void = () => {};
</script>
<header class="workspace-topbar">
<div class="brand-mark" aria-label="SenlinAI">
<span class="brand-logo">S</span>
<span>SenlinAI</span>
</div>
<form class="search-box" role="search" aria-label="Workspace search" on:submit|preventDefault>
<label class="sr-only" for="global-search">Search</label>
<input id="global-search" aria-label="Global search" placeholder="搜索项目、频道、文档、任务、联系人..." />
<button type="submit" aria-label="Search workspace">搜索</button>
</form>
<div class="topbar-actions">
<button class="theme-toggle" type="button" aria-label={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'} on:click={onToggleTheme}>
<span aria-hidden="true">{theme === 'light' ? '☾' : '☀'}</span>
</button>
<button aria-label="Back" disabled></button>
<button aria-label="Forward" disabled></button>
<button aria-label="Notifications" disabled>!</button>
<button aria-label="Help" disabled>?</button>
</div>
</header>

View File

@@ -1,27 +0,0 @@
<script lang="ts">
import type { AISessionItem, InspectorItem } from '../types';
export let sessions: AISessionItem[];
export let onInspect: (item: InspectorItem) => void;
let selectedSessionID = sessions[0]?.id;
$: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0];
function inspectSession(session: AISessionItem) {
onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] });
}
</script>
<section class="channel-page ai-sessions-channel">
<header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header>
<div class="session-layout">
<div class="session-list">
{#each sessions as session}
<button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button>
{/each}
</div>
{#if selectedSession}
<article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article>
{/if}
</div>
</section>

View File

@@ -1,19 +0,0 @@
<script lang="ts">
import type { CronPlan, InspectorItem } from '../types';
export let plans: CronPlan[];
export let onInspect: (item: InspectorItem) => void;
function inspectPlan(plan: CronPlan) {
onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] });
}
</script>
<section class="channel-page cron-channel">
<header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header>
<div class="cron-list">
{#each plans as plan}
<article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article>
{/each}
</div>
</section>

View File

@@ -1,34 +0,0 @@
<script lang="ts">
import type { WorkbenchChannel } from '../types';
export let channel: WorkbenchChannel;
let copyStatus = '';
let copyFailed = false;
async function copyUrl() {
if (!navigator.clipboard?.writeText) {
copyFailed = true;
copyStatus = 'Copy unavailable in this browser.';
return;
}
try {
await navigator.clipboard.writeText(channel.url ?? '');
copyFailed = false;
copyStatus = 'URL copied.';
} catch {
copyFailed = true;
copyStatus = 'Unable to copy URL.';
}
}
</script>
<section class="channel-page custom-link-channel">
<header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header>
<p>{channel.url}</p>
<a class="custom-link" href={channel.url} target="_blank" rel="noreferrer">Open external channel</a>
<button class="copy-url-button" type="button" on:click={copyUrl}>Copy URL</button>
{#if copyStatus}
<p class:copy-error={copyFailed} class="copy-feedback" aria-live="polite">{copyStatus}</p>
{/if}
</section>

View File

@@ -1,70 +0,0 @@
import '@testing-library/jest-dom/vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import CustomLinkChannel from './CustomLinkChannel.svelte';
const channel = {
id: 'project-custom-link',
projectID: 1,
type: 'custom_link' as const,
title: 'Roadmap Board',
icon: 'link',
url: 'https://example.com/roadmap',
sortOrder: 1,
};
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
function setClipboard(writeText: Clipboard['writeText']) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText } as Clipboard,
});
}
afterEach(() => {
cleanup();
if (originalClipboard) {
Object.defineProperty(navigator, 'clipboard', originalClipboard);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
});
describe('CustomLinkChannel', () => {
it('shows copy success only after clipboard writing resolves', async () => {
let resolveWrite: (() => void) | undefined;
const writeText = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveWrite = resolve;
}),
);
setClipboard(writeText);
render(CustomLinkChannel, { props: { channel } });
await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
expect(screen.queryByText('URL copied.')).not.toBeInTheDocument();
resolveWrite?.();
await waitFor(() => expect(screen.getByText('URL copied.')).toBeInTheDocument());
});
it('reports a copy failure when clipboard writing rejects', async () => {
setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied')));
render(CustomLinkChannel, { props: { channel } });
await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
await waitFor(() => expect(screen.getByText('Unable to copy URL.')).toBeInTheDocument());
});
it('reports unavailable clipboard access without showing URL copied', async () => {
Reflect.deleteProperty(navigator, 'clipboard');
render(CustomLinkChannel, { props: { channel } });
await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
await waitFor(() => expect(screen.getByText('Copy unavailable in this browser.')).toBeInTheDocument());
expect(screen.queryByText('URL copied.')).not.toBeInTheDocument();
});
});

View File

@@ -1,22 +0,0 @@
<script lang="ts">
import type { InboxMessage, InspectorItem } from '../types';
export let messages: InboxMessage[];
export let onInspect: (item: InspectorItem) => void;
function inspectMessage(message: InboxMessage) {
onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] });
}
</script>
<section class="channel-page inbox-channel">
<header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header>
<div class="message-list">
{#each messages as message}
<article class="message-row">
<div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div>
<button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button>
</article>
{/each}
</div>
</section>

View File

@@ -1,19 +0,0 @@
<script lang="ts">
import type { InspectorItem, NoteSourceItem } from '../types';
export let items: NoteSourceItem[];
export let onInspect: (item: InspectorItem) => void;
function inspectItem(item: NoteSourceItem) {
onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] });
}
</script>
<section class="channel-page notes-sources-channel">
<header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header>
<div class="file-list">
{#each items as item}
<article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article>
{/each}
</div>
</section>

View File

@@ -1,148 +0,0 @@
<script lang="ts">
import type { InspectorItem, ProjectWorkspace } from '../types';
export let workspace: ProjectWorkspace;
export let onInspect: (item: InspectorItem) => void;
const metrics = (workspace: ProjectWorkspace) => [
{ label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' },
{ label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' },
{ label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' },
{ label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' },
{ label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' },
];
</script>
<section class="channel-page overview-channel">
<header class="channel-header">
<div>
<p>{workspace.project.name}</p>
<h1>Overview</h1>
</div>
<div class="overview-tools" aria-label="Overview filters">
<span>2026-07-20</span>
<button type="button" aria-label="More overview actions">...</button>
</div>
</header>
<div class="metric-list">
{#each metrics(workspace) as metric}
<button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
<strong>{metric.value}</strong>
<span>{metric.label}</span>
</button>
{/each}
</div>
<div class="overview-grid">
<section class="overview-section">
<header>
<h2>Inbox 待处理</h2>
<a href="/" on:click|preventDefault>查看全部</a>
</header>
<div class="overview-rows">
{#each workspace.inbox as message}
<button
type="button"
class="overview-row"
on:click={() => onInspect({
title: message.title,
type: 'Inbox message',
description: message.summary,
properties: [
{ label: 'Source', value: message.source },
{ label: 'Status', value: message.status },
{ label: 'Tag', value: message.tag },
],
})}
>
<span>{message.title}</span>
<small>来源 {message.source} · #{message.tag}</small>
<time>{message.time}</time>
</button>
{/each}
</div>
</section>
<section class="overview-section">
<header>
<h2>进行中的任务</h2>
<a href="/" on:click|preventDefault>查看全部</a>
</header>
<div class="overview-rows">
{#each workspace.tasks as task}
<button
type="button"
class="overview-row"
on:click={() => onInspect({
title: task.title,
type: 'Task',
description: task.summary,
properties: [
{ label: 'Status', value: task.completed ? 'Done' : 'Open' },
{ label: 'Owner', value: task.owner },
{ label: 'Due', value: task.due },
],
})}
>
<span>{task.title}</span>
<small>负责人 {task.owner} · #{task.tag}</small>
<time>{task.due}</time>
</button>
{/each}
</div>
</section>
<section class="overview-section">
<header>
<h2>AI 会话</h2>
<a href="/" on:click|preventDefault>查看全部</a>
</header>
<div class="overview-rows">
{#each workspace.aiSessions as session}
<button
type="button"
class="overview-row"
on:click={() => onInspect({
title: session.title,
type: 'AI session',
description: session.summary,
properties: session.references.map((reference) => ({ label: 'Reference', value: reference })),
})}
>
<span>{session.title}</span>
<small>{session.summary}</small>
<time>{session.updatedAt}</time>
</button>
{/each}
</div>
</section>
<section class="overview-section">
<header>
<h2>最近笔记资料</h2>
<a href="/" on:click|preventDefault>查看全部</a>
</header>
<div class="overview-rows">
{#each workspace.notesSources as item}
<button
type="button"
class="overview-row"
on:click={() => onInspect({
title: item.title,
type: item.kind,
description: `${item.source} · ${item.tag}`,
properties: [
{ label: 'Updated', value: item.updatedAt },
{ label: 'Tag', value: item.tag },
],
})}
>
<span>{item.title}</span>
<small>{item.source} · #{item.tag}</small>
<time>{item.updatedAt}</time>
</button>
{/each}
</div>
</section>
</div>
</section>

View File

@@ -1,36 +0,0 @@
<script lang="ts">
import type { InspectorItem, WorkTask } from '../types';
export let tasks: WorkTask[];
export let onInspect: (item: InspectorItem) => void;
export let onToggleTask: (taskID: string) => void = () => {};
function inspectTask(task: WorkTask) {
onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] });
}
</script>
<section class="channel-page tasks-channel">
<header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header>
<div class="task-list">
{#each tasks as task}
<article class:completed={task.completed} class="task-card">
<input
class="task-check"
type="checkbox"
aria-label={`Mark ${task.title} complete`}
checked={task.completed}
on:change={() => onToggleTask(task.id)}
/>
<div>
<h2>{task.title}</h2>
<p>{task.summary}</p>
<small>{task.owner} / {task.due} / #{task.tag}</small>
<span class="task-status">{task.completed ? 'Completed' : 'Open'}</span>
</div>
<button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button>
</article>
{/each}
</div>
</section>

View File

@@ -1,29 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getProjectWorkspace, workbenchProjects } from './mockData';
describe('workbench mock data', () => {
it('provides dynamic projects', () => {
expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
});
it('loads project-specific channels and counts', () => {
const first = getProjectWorkspace(workbenchProjects[0].id);
const second = getProjectWorkspace(workbenchProjects[1].id);
expect(first.project.id).not.toBe(second.project.id);
expect(first.channels.map((channel) => channel.type)).toEqual([
'overview',
'inbox',
'tasks',
'ai_sessions',
'notes_sources',
'cron',
'custom_link',
]);
expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
title: expect.any(String),
url: expect.stringMatching(/^https?:\/\//),
});
});
});

View File

@@ -1,61 +0,0 @@
import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
export const workbenchProjects: WorkbenchProject[] = [
{ id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
{ id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
];
function systemChannels(projectID: number): WorkbenchChannel[] {
return [
{ id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
{ id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
{ id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
{ id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
{ id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
{ id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
{
id: `${projectID}-custom-roadmap`,
projectID,
type: 'custom_link',
title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
icon: 'link',
url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
sortOrder: 7,
},
];
}
const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
project,
channels: systemChannels(project.id),
tags: ['all', 'tag1', 'UI', 'knowledge'],
recentSessions: [
{ id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
{ id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
],
inbox: [
{ id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
{ id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
],
tasks: [
{ id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
{ id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
],
aiSessions: [
{ id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
{ id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
],
notesSources: [
{ id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
{ id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
{ id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
],
cronPlans: [
{ id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
{ id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
],
}));
export function getProjectWorkspace(projectID: number): ProjectWorkspace {
return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
}

View File

@@ -1,93 +0,0 @@
export type ChannelType =
| 'overview'
| 'inbox'
| 'tasks'
| 'ai_sessions'
| 'notes_sources'
| 'cron'
| 'custom_link';
export type WorkbenchProject = {
id: number;
name: string;
description: string;
initials: string;
unreadCount: number;
};
export type WorkbenchChannel = {
id: string;
projectID: number;
type: ChannelType;
title: string;
icon: string;
count?: number;
url?: string;
sortOrder: number;
};
export type InboxMessage = {
id: string;
source: string;
title: string;
summary: string;
status: 'open' | 'processed' | 'archived';
tag: string;
time: string;
};
export type WorkTask = {
id: string;
title: string;
summary: string;
completed: boolean;
owner: string;
due: string;
tag: string;
};
export type AISessionItem = {
id: string;
title: string;
summary: string;
updatedAt: string;
references: string[];
};
export type NoteSourceItem = {
id: string;
kind: 'note' | 'file' | 'link';
title: string;
updatedAt: string;
tag: string;
source: string;
};
export type CronPlan = {
id: string;
title: string;
schedule: string;
nextRun: string;
enabled: boolean;
lastResult: string;
owner: string;
};
export type InspectorItem = {
title: string;
type: string;
description: string;
properties: Array<{ label: string; value: string }>;
};
export type ProjectWorkspace = {
project: WorkbenchProject;
channels: WorkbenchChannel[];
tags: string[];
recentSessions: AISessionItem[];
inbox: InboxMessage[];
tasks: WorkTask[];
aiSessions: AISessionItem[];
notesSources: NoteSourceItem[];
cronPlans: CronPlan[];
};

View File

@@ -1,844 +0,0 @@
:root {
color: #1d2129;
background: #f7f8fa;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
--background: #f7f8fa;
--panel: #ffffff;
--panel-muted: #f2f3f5;
--border: #e5e6eb;
--text: #1d2129;
--muted: #86909c;
--subtle: #4e5969;
--primary: #165dff;
--primary-strong: #0e42d2;
--primary-soft: #e8f3ff;
--success: #00b42a;
--warning: #ff7d00;
--danger: #f53f3f;
--statusbar: #142033;
--statusbar-border: #22314a;
--radius: 6px;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
button:disabled { cursor: not-allowed; opacity: 0.58; }
a { color: inherit; text-decoration: none; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 28px;
background:
radial-gradient(circle at top left, rgba(22, 93, 255, 0.08), transparent 32%),
var(--background);
}
.login-panel {
width: min(1200px, 100%);
min-height: 420px;
display: grid;
grid-template-columns: 1fr 1.1fr 1fr;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
box-shadow: 0 10px 30px rgba(29, 33, 41, 0.08);
}
.login-brand-panel,
.server-login,
.login-value-panel {
padding: 36px 42px;
}
.login-brand-panel {
display: grid;
align-content: center;
gap: 10px;
border-right: 1px solid var(--border);
}
.login-brand {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 10px;
background: var(--primary);
color: white;
font-weight: 800;
font-size: 1.35rem;
}
.login-brand-panel h1,
.login-heading h2,
.login-value-panel h2 {
margin: 0;
color: var(--text);
}
.login-brand-panel h1 { font-size: 1.8rem; }
.login-brand-panel p,
.login-value-panel p {
margin: 0;
color: var(--subtle);
}
.login-brand-panel small,
.login-links,
.connection-status,
.login-note,
.form-error,
.channel-header p,
.channel-page small,
.channel-page > p,
.object-inspector > p {
margin: 0;
color: var(--muted);
font-size: 0.875rem;
}
.login-links {
align-self: end;
display: flex;
gap: 18px;
margin-top: 72px;
}
.server-login,
.login-heading,
.channel-page,
.object-inspector,
.channel-sidebar {
display: grid;
gap: 12px;
}
.login-heading { gap: 4px; }
.login-heading p { margin: 0; color: var(--text); font-weight: 600; }
.login-heading h2 { font-size: 1rem; font-weight: 500; color: var(--muted); }
.server-login {
border-right: 1px solid var(--border);
}
.server-login label {
color: var(--subtle);
font-size: 0.8125rem;
font-weight: 600;
}
.server-login input,
.search-box input {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius);
background: white;
color: var(--text);
}
.server-login input {
height: 36px;
padding: 8px 10px;
}
.server-login button,
.primary-action {
height: 38px;
border: 1px solid var(--primary);
border-radius: var(--radius);
background: var(--primary);
color: white;
padding: 0 14px;
font-weight: 600;
}
.form-error { color: var(--danger); }
.connection-status {
border: 1px solid #7be188;
border-radius: var(--radius);
background: #f0fff4;
color: #168a2f;
padding: 8px 10px;
}
.login-value-panel {
display: grid;
align-content: center;
gap: 18px;
}
.login-value-panel ul {
display: grid;
gap: 18px;
margin: 0;
padding: 0;
list-style: none;
}
.login-value-panel li {
display: grid;
gap: 3px;
padding-left: 16px;
border-left: 3px solid var(--primary-soft);
}
.login-value-panel strong { color: var(--text); }
.login-value-panel span { color: var(--muted); font-size: 0.875rem; }
.workbench-shell {
min-height: 100vh;
display: grid;
grid-template-rows: 58px minmax(0, 1fr) 32px;
background: var(--background);
color: var(--text);
}
.workbench-shell[data-theme='dark'] {
--background: #1e1e1e;
--panel: #252526;
--panel-muted: #2d2d30;
--border: #3c3c3c;
--text: #cccccc;
--muted: #858585;
--subtle: #a7a7a7;
--primary: #007acc;
--primary-strong: #1890e8;
--primary-soft: rgba(0, 122, 204, 0.22);
--statusbar: #007acc;
--statusbar-border: #1f6feb;
}
.workspace-topbar {
display: grid;
grid-template-columns: 192px minmax(420px, 1fr) auto;
align-items: center;
gap: 18px;
border-bottom: 1px solid var(--border);
background: var(--panel);
padding: 0 16px;
}
.brand-mark {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.05rem;
font-weight: 700;
}
.brand-logo {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 7px;
background: var(--primary);
color: white;
font-weight: 800;
}
.search-box {
display: grid;
grid-template-columns: minmax(0, 1fr) 72px;
align-items: center;
max-width: 680px;
min-width: 0;
height: 42px;
border: 1px solid var(--primary);
border-radius: 7px;
background: var(--panel);
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.06);
}
.search-box input {
height: 40px;
border: 0;
padding: 0 16px;
background: transparent;
}
.search-box button {
height: 40px;
border: 0;
border-left: 1px solid var(--primary);
border-radius: 0 6px 6px 0;
background: var(--primary);
color: white;
font-weight: 600;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.topbar-actions button {
width: 32px;
height: 32px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel);
color: var(--subtle);
}
.topbar-actions .theme-toggle {
width: 56px;
background: var(--panel-muted);
color: var(--primary);
}
.workbench-body {
min-height: 0;
display: grid;
grid-template-columns: 88px 248px minmax(0, 1fr) 330px;
}
.project-rail,
.channel-sidebar,
.object-inspector {
border-right: 1px solid var(--border);
background: var(--panel);
}
.project-rail {
display: grid;
align-content: start;
gap: 12px;
padding: 14px 12px;
}
.project-rail button {
position: relative;
width: 56px;
min-height: 56px;
display: grid;
place-items: center;
gap: 2px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-muted);
color: var(--subtle);
}
.project-rail button.active {
border-color: var(--primary);
background: var(--primary);
color: white;
}
.project-initials { font-weight: 700; }
.project-rail small {
position: absolute;
top: -6px;
right: -7px;
min-width: 19px;
height: 19px;
display: grid;
place-items: center;
border: 2px solid var(--panel);
border-radius: 999px;
background: var(--primary);
color: white;
font-size: 0.625rem;
line-height: 1;
}
.project-rail small.urgent { background: var(--danger); }
.channel-sidebar,
.object-inspector,
.channel-stage {
padding: 16px;
overflow: auto;
}
.channel-sidebar {
align-content: start;
justify-items: start;
}
.channel-sidebar > header,
.channel-sidebar > .channel-group,
.channel-sidebar > .recent-sessions {
width: 100%;
}
.channel-sidebar header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
}
.channel-sidebar header p,
.channel-sidebar h2,
.channel-sidebar h3 { margin: 0; }
.channel-sidebar h2 { font-size: 1rem; }
.channel-sidebar h3 { color: var(--muted); font-size: 0.8125rem; font-weight: 600; }
.channel-sidebar header button {
width: 30px;
height: 30px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel-muted);
}
.tag-row,
.recent-sessions ul {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.tag-row li {
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 8px;
background: var(--panel-muted);
color: var(--muted);
font-size: 0.75rem;
}
.channel-group,
.task-list,
.record-list,
.message-list,
.file-list,
.cron-list {
display: grid;
gap: 8px;
}
.channel-group button {
width: 100%;
min-height: 34px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
border: 0;
border-radius: var(--radius);
background: transparent;
color: var(--text);
padding: 7px 9px;
text-align: left;
}
.channel-group button.active {
color: var(--primary);
background: var(--primary-soft);
box-shadow: inset 3px 0 0 var(--primary);
}
.channel-group small {
min-width: 26px;
border-radius: 999px;
background: var(--panel-muted);
color: var(--muted);
padding: 1px 6px;
text-align: center;
}
.channel-icon { color: currentColor; text-align: center; }
.recent-sessions { display: grid; gap: 8px; }
.recent-sessions li { display: grid; gap: 2px; min-width: 0; padding: 7px 8px; border-radius: var(--radius); }
.recent-sessions span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8125rem; }
.recent-sessions small { color: var(--muted); font-size: 0.6875rem; }
.channel-stage { background: var(--background); }
.channel-page { align-content: start; }
.channel-header {
display: flex;
align-items: end;
justify-content: space-between;
border-bottom: 1px solid var(--border);
padding-bottom: 12px;
}
.channel-header h1,
.channel-page h2 { margin: 0; }
.channel-header h1 { font-size: 1.125rem; }
.channel-page h2 { font-size: 0.9375rem; }
.channel-page h2 + p { margin: 4px 0; }
.overview-tools {
display: flex;
align-items: center;
gap: 8px;
}
.overview-tools span,
.overview-tools button {
height: 30px;
display: inline-flex;
align-items: center;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel);
color: var(--subtle);
padding: 0 10px;
font-size: 0.8125rem;
}
.overview-tools button { width: 34px; justify-content: center; }
.metric-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 10px;
}
.metric-card,
.task-card,
.record-row,
.cron-row,
.message-row,
.file-row,
.conversation-summary {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel);
padding: 10px 12px;
}
.metric-card {
display: grid;
gap: 4px;
text-align: left;
}
.metric-card strong { font-size: 1.45rem; color: var(--text); }
.metric-card span { color: var(--muted); font-size: 0.8125rem; }
.overview-grid {
display: grid;
gap: 12px;
}
.overview-section {
display: grid;
gap: 6px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel);
padding: 10px 12px;
}
.overview-section header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.overview-section h2 {
font-size: 0.875rem;
}
.overview-section a {
color: var(--primary);
font-size: 0.75rem;
font-weight: 600;
}
.overview-rows {
display: grid;
}
.overview-row {
width: 100%;
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(180px, 1.1fr) auto;
gap: 12px;
align-items: center;
border: 0;
border-top: 1px solid var(--border);
background: transparent;
color: var(--text);
padding: 8px 0;
text-align: left;
}
.overview-row:first-child {
border-top: 0;
}
.overview-row span,
.overview-row small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.overview-row span {
font-weight: 600;
}
.overview-row small,
.overview-row time {
color: var(--muted);
font-size: 0.75rem;
}
.task-card,
.record-row,
.cron-row,
.message-row,
.file-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
}
.task-card.completed {
color: var(--muted);
background: var(--panel-muted);
}
.task-check { width: 18px; height: 18px; margin: 3px 0 0; accent-color: var(--primary); }
.task-status { display: inline-block; margin-top: 6px; color: var(--muted); font-size: 0.75rem; }
.message-row { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
.file-row { grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; }
.file-row > span { min-width: 40px; border: 1px solid var(--border); border-radius: 4px; padding: 3px 5px; color: var(--muted); font-size: 0.6875rem; text-align: center; text-transform: uppercase; }
.message-row p,
.cron-row p,
.conversation-summary p { margin: 4px 0; }
.message-row button,
.file-row button,
.task-card > button,
.cron-row button,
.conversation-summary button,
.copy-url-button,
.object-inspector button {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel);
color: var(--text);
padding: 6px 9px;
}
.session-layout { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(0, 1.3fr); gap: 12px; }
.session-list { display: grid; align-content: start; gap: 6px; }
.session-list button { display: grid; gap: 3px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); padding: 9px; text-align: left; }
.session-list button.active { border-color: var(--primary); background: var(--primary-soft); }
.session-list small { color: var(--muted); }
.conversation-summary { align-content: start; }
.custom-link { color: var(--primary-strong); font-weight: 600; width: max-content; }
.copy-url-button { width: max-content; }
.copy-feedback { margin: 0; color: var(--success); font-size: 0.8125rem; }
.copy-feedback.copy-error { color: var(--danger); }
.object-inspector {
align-content: start;
grid-auto-rows: min-content;
}
.inspector-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-content: start;
grid-auto-rows: min-content;
gap: 4px;
}
.inspector-tabs button[aria-pressed='true'] {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-soft);
}
.workspace-statusbar {
min-height: 32px;
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(420px, 1.2fr) minmax(260px, 0.9fr);
align-items: center;
gap: 14px;
border-top: 1px solid var(--statusbar-border);
background: var(--statusbar);
color: rgba(255, 255, 255, 0.82);
padding: 0 14px;
font-size: 0.75rem;
}
.statusbar-user,
.statusbar-tasks,
.statusbar-system {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.statusbar-tasks { justify-content: center; color: rgba(255, 255, 255, 0.86); }
.statusbar-system { justify-content: end; color: rgba(255, 255, 255, 0.72); }
.user-avatar {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 999px;
background: var(--primary);
color: white;
font-size: 0.6875rem;
font-weight: 700;
}
.user-meta {
min-width: 0;
display: grid;
line-height: 1.05;
}
.user-meta strong {
max-width: 160px;
overflow: hidden;
color: white;
font-size: 0.75rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-meta small { color: #7be188; font-size: 0.6875rem; }
.workspace-statusbar button {
height: 24px;
border: 0;
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.78);
padding: 0 6px;
}
.workspace-statusbar button:hover { background: rgba(255, 255, 255, 0.1); }
@media (max-width: 920px) {
.login-panel {
grid-template-columns: 1fr;
}
.login-brand-panel,
.server-login {
border-right: 0;
border-bottom: 1px solid var(--border);
}
.workspace-topbar {
grid-template-columns: minmax(0, 1fr) auto;
height: auto;
padding: 12px;
}
.search-box { grid-column: 1 / -1; grid-row: 2; max-width: none; }
.topbar-actions { grid-column: 2; grid-row: 1; }
.workbench-shell {
grid-template-rows: auto 1fr auto;
}
.workbench-body {
grid-template-columns: 1fr;
align-content: start;
}
.project-rail,
.channel-sidebar,
.object-inspector {
border-right: 0;
border-bottom: 1px solid var(--border);
}
.project-rail {
grid-template-columns: none;
grid-auto-flow: column;
grid-auto-columns: 56px;
align-items: center;
max-height: 84px;
overflow-x: auto;
overflow-y: hidden;
padding: 10px 12px;
}
.channel-sidebar {
align-content: start;
max-height: 230px;
padding: 12px;
}
.channel-group,
.tag-row,
.recent-sessions ul {
grid-auto-flow: column;
grid-auto-columns: minmax(132px, max-content);
grid-template-columns: none;
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 2px;
}
.channel-group {
grid-auto-flow: row;
grid-auto-columns: auto;
grid-template-columns: repeat(3, minmax(0, 1fr));
overflow: visible;
}
.channel-group button {
min-width: 0;
min-height: 48px;
grid-template-columns: 18px minmax(0, 1fr);
padding: 8px;
}
.channel-group button > span:last-of-type {
overflow: hidden;
text-overflow: ellipsis;
}
.channel-group small { display: none; }
.recent-sessions li { min-width: 160px; }
.channel-stage { max-height: 36vh; overflow: auto; }
.object-inspector { max-height: 288px; overflow: auto; }
.session-layout { grid-template-columns: 1fr; }
.workspace-statusbar {
grid-template-columns: 1fr;
gap: 6px;
padding: 8px 12px;
}
.statusbar-tasks,
.statusbar-system {
justify-content: start;
flex-wrap: wrap;
}
}

View File

@@ -1,25 +0,0 @@
export type Project = { id: number; name: string; description: string };
export type ProjectDashboardSummary = {
project_id: number;
pending_inbox_count: number;
open_task_count: number;
recent_note_count: number;
recent_session_count: number;
};
export function createApi(apiBase: string, token?: string) {
async function request<T>(path: string): Promise<T> {
const response = await fetch(`${apiBase}/api${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) throw new Error(`API request failed: ${response.status}`);
return response.json() as Promise<T>;
}
return {
getProjects: () => request<Project[]>('/projects'),
getProjectDashboard: (projectID: number) =>
request<ProjectDashboardSummary>(`/projects/${projectID}/dashboard`),
};
}

View File

@@ -1,11 +0,0 @@
import './index.css';
import App from './app/App.svelte';
import { mount } from 'svelte';
const target = document.getElementById('app');
if (!target) {
throw new Error('Missing #app mount target');
}
mount(App, { target });

View File

@@ -1,2 +0,0 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

View File

@@ -1,17 +0,0 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force",
"types": ["svelte", "vite/client", "vitest/globals"],
"noEmit": true,
"strict": true
},
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
}

View File

@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -1,23 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,14 +0,0 @@
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [svelte()],
resolve: {
conditions: ['browser'],
},
test: {
environment: 'jsdom',
exclude: ['node_modules/**', 'dist/**', 'e2e/**'],
globals: true,
},
});

View File

@@ -2,7 +2,7 @@
SenlinAI primary web client built with React, TypeScript, Vite, and Arco Design. SenlinAI primary web client built with React, TypeScript, Vite, and Arco Design.
This app is now the active web client under `apps`. The legacy `apps/web` Svelte app is no longer the primary frontend. This app is the active web client under `apps/web_v1`.
## Scripts ## Scripts

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>senlinai-acro-react</title> <title>web_v1</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,11 +1,11 @@
{ {
"name": "senlinai-acro-react", "name": "web_v1",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "senlinai-acro-react", "name": "web_v1",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@arco-design/web-react": "^2.66.16", "@arco-design/web-react": "^2.66.16",

View File

@@ -1,5 +1,5 @@
{ {
"name": "senlinai-acro-react", "name": "web_v1",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",

View File

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -26,12 +26,12 @@ Expected: `TestPostgresPing` passes.
## Web ## Web
The primary frontend is the React + Arco Design client under `apps/senlinai-acro-react`. The primary frontend is the React + Arco Design client under `apps/web_v1`.
Run: Run:
```powershell ```powershell
Set-Location apps/senlinai-acro-react Set-Location apps/web_v1
node scripts/visual-check.mjs node scripts/visual-check.mjs
npm run build npm run build
npm run lint npm run lint
@@ -44,7 +44,7 @@ Expected: visual smoke checks, production build, and lint pass.
Run: Run:
```powershell ```powershell
Set-Location apps/senlinai-acro-react Set-Location apps/web_v1
npm run build npm run build
Set-Location ../desktop Set-Location ../desktop
npm run build npm run build
@@ -70,9 +70,9 @@ Current machine status:
Last audited: 2026-07-18. Last audited: 2026-07-18.
- `backend`: `go test ./... -v` passed. - `backend`: `go test ./... -v` passed.
- `apps/senlinai-acro-react`: `node scripts/visual-check.mjs` passed. - `apps/web_v1`: `node scripts/visual-check.mjs` passed.
- `apps/senlinai-acro-react`: `npm run build` passed. - `apps/web_v1`: `npm run build` passed.
- `apps/senlinai-acro-react`: `npm run lint` passed. - `apps/web_v1`: `npm run lint` passed.
- `apps/desktop`: `npx tauri build --no-bundle --verbose` passed and produced the desktop executable. - `apps/desktop`: `npx tauri build --no-bundle --verbose` passed and produced the desktop executable.
- `apps/desktop`: installer packaging is still blocked by Tauri CLI bundling timeout on the current machine. - `apps/desktop`: installer packaging is still blocked by Tauri CLI bundling timeout on the current machine.

View File

@@ -3,8 +3,8 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR/apps/web" cd "$ROOT_DIR/apps/web_v1"
npm run build npm run build
echo "Web build output: $ROOT_DIR/apps/web/dist" echo "Web build output: $ROOT_DIR/apps/web_v1/dist"

Some files were not shown because too many files have changed in this diff Show More