235 lines
8.6 KiB
Markdown
235 lines
8.6 KiB
Markdown
# Review package Task 3
|
|
|
|
## Commits
|
|
95dce81 feat: add project channel workbench shell
|
|
|
|
## Stat
|
|
apps/web/src/app/App.svelte | 6 +--
|
|
.../workbench/ProjectChannelSidebar.svelte | 52 ++++++++++++++++++++++
|
|
apps/web/src/features/workbench/ProjectRail.svelte | 26 +++++++++++
|
|
.../src/features/workbench/ProjectWorkbench.svelte | 41 +++++++++++++++++
|
|
.../features/workbench/ProjectWorkbench.test.ts | 26 +++++++++++
|
|
.../src/features/workbench/WorkspaceTopbar.svelte | 16 +++++++
|
|
6 files changed, 163 insertions(+), 4 deletions(-)
|
|
|
|
## Diff
|
|
diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte
|
|
index a5d8dce..e7170ef 100644
|
|
--- a/apps/web/src/app/App.svelte
|
|
+++ b/apps/web/src/app/App.svelte
|
|
@@ -1,21 +1,19 @@
|
|
<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}
|
|
- <main class="workbench-placeholder" aria-label="Project workbench">
|
|
- <h1>SenlinAI Workbench</h1>
|
|
- <p>Signed in as {currentUser.account}</p>
|
|
- </main>
|
|
+ <ProjectWorkbench {currentUser} />
|
|
{:else}
|
|
<ServerLogin {apiBase} onLogin={handleLogin} />
|
|
{/if}
|
|
diff --git a/apps/web/src/features/workbench/ProjectChannelSidebar.svelte b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
|
|
new file mode 100644
|
|
index 0000000..40cb1a0
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
|
|
@@ -0,0 +1,52 @@
|
|
+<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;
|
|
+</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">⚙</button>
|
|
+ </header>
|
|
+
|
|
+ <section class="tag-row" aria-label="Project tags">
|
|
+ {#each tags as tag}
|
|
+ <button type="button">#{tag}</button>
|
|
+ {/each}
|
|
+ </section>
|
|
+
|
|
+ <section class="channel-group">
|
|
+ {#each channels 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>{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>
|
|
+ {#each recentSessions as session}
|
|
+ <button type="button">
|
|
+ <span>{session.title}</span>
|
|
+ <small>{session.updatedAt}</small>
|
|
+ </button>
|
|
+ {/each}
|
|
+ </section>
|
|
+</aside>
|
|
diff --git a/apps/web/src/features/workbench/ProjectRail.svelte b/apps/web/src/features/workbench/ProjectRail.svelte
|
|
new file mode 100644
|
|
index 0000000..d1be341
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/ProjectRail.svelte
|
|
@@ -0,0 +1,26 @@
|
|
+<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">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>{project.initials}</span>
|
|
+ {#if project.unreadCount}
|
|
+ <small>{project.unreadCount}</small>
|
|
+ {/if}
|
|
+ </button>
|
|
+ {/each}
|
|
+ <button class="add-project" aria-label="Create project">+</button>
|
|
+</nav>
|
|
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
|
new file mode 100644
|
|
index 0000000..4ab9ffd
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
|
@@ -0,0 +1,41 @@
|
|
+<script lang="ts">
|
|
+ import { getProjectWorkspace, workbenchProjects } from './mockData';
|
|
+ import ProjectRail from './ProjectRail.svelte';
|
|
+ import ProjectChannelSidebar from './ProjectChannelSidebar.svelte';
|
|
+ import WorkspaceTopbar from './WorkspaceTopbar.svelte';
|
|
+
|
|
+ export let currentUser: { account: string };
|
|
+
|
|
+ let selectedProjectID = workbenchProjects[0].id;
|
|
+ $: workspace = getProjectWorkspace(selectedProjectID);
|
|
+ $: selectedChannelID = workspace.channels[0].id;
|
|
+
|
|
+ function selectProject(projectID: number) {
|
|
+ selectedProjectID = projectID;
|
|
+ }
|
|
+
|
|
+ function selectChannel(channelID: string) {
|
|
+ selectedChannelID = channelID;
|
|
+ }
|
|
+</script>
|
|
+
|
|
+<main class="workbench-shell">
|
|
+ <WorkspaceTopbar account={currentUser.account} />
|
|
+ <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">
|
|
+ <h1>{workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}</h1>
|
|
+ </section>
|
|
+ <aside class="object-inspector" aria-label="Object inspector">
|
|
+ <p>Select an item to inspect discussion, properties, and actions.</p>
|
|
+ </aside>
|
|
+ </div>
|
|
+</main>
|
|
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
|
new file mode 100644
|
|
index 0000000..cf251b3
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
|
|
@@ -0,0 +1,26 @@
|
|
+import '@testing-library/jest-dom/vitest';
|
|
+import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
+import { describe, expect, it } 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 channels')).toBeInTheDocument();
|
|
+ expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
|
|
+ expect(screen.getByLabelText('Global search')).toBeInTheDocument();
|
|
+ expect(screen.getByLabelText('Object inspector')).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();
|
|
+ });
|
|
+});
|
|
diff --git a/apps/web/src/features/workbench/WorkspaceTopbar.svelte b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
|
|
new file mode 100644
|
|
index 0000000..c828fcf
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
|
|
@@ -0,0 +1,16 @@
|
|
+<script lang="ts">
|
|
+ export let account: string;
|
|
+</script>
|
|
+
|
|
+<header class="workspace-topbar">
|
|
+ <div class="brand-mark">SenlinAI</div>
|
|
+ <label class="search-box">
|
|
+ <span>Search</span>
|
|
+ <input aria-label="Global search" placeholder="Search projects, tasks, notes, AI sessions" />
|
|
+ </label>
|
|
+ <div class="topbar-actions">
|
|
+ <button aria-label="Back">←</button>
|
|
+ <button aria-label="Forward">→</button>
|
|
+ <button aria-label={`Account ${account}`}>{account.slice(0, 2).toUpperCase()}</button>
|
|
+ </div>
|
|
+</header>
|