399 lines
18 KiB
Markdown
399 lines
18 KiB
Markdown
# Review package Task 4
|
|
|
|
## Commits
|
|
3194cbe feat: add workbench channel templates
|
|
|
|
## Stat
|
|
.../src/features/workbench/ChannelContent.svelte | 30 +++++++++++++++++
|
|
.../src/features/workbench/ChannelContent.test.ts | 39 ++++++++++++++++++++++
|
|
.../src/features/workbench/ObjectInspector.svelte | 37 ++++++++++++++++++++
|
|
.../src/features/workbench/ProjectWorkbench.svelte | 15 ++++++---
|
|
.../workbench/channels/AISessionsChannel.svelte | 27 +++++++++++++++
|
|
.../features/workbench/channels/CronChannel.svelte | 19 +++++++++++
|
|
.../workbench/channels/CustomLinkChannel.svelte | 16 +++++++++
|
|
.../workbench/channels/InboxChannel.svelte | 22 ++++++++++++
|
|
.../workbench/channels/NotesSourcesChannel.svelte | 19 +++++++++++
|
|
.../workbench/channels/OverviewChannel.svelte | 29 ++++++++++++++++
|
|
.../workbench/channels/TasksChannel.svelte | 23 +++++++++++++
|
|
11 files changed, 272 insertions(+), 4 deletions(-)
|
|
|
|
## Diff
|
|
diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte
|
|
new file mode 100644
|
|
index 0000000..9e81962
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/ChannelContent.svelte
|
|
@@ -0,0 +1,30 @@
|
|
+<script lang="ts">
|
|
+ import type { InspectorItem, ProjectWorkspace, 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 channel: WorkbenchChannel;
|
|
+ export let onInspect: (item: InspectorItem) => 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={workspace.tasks} {onInspect} />
|
|
+{: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}
|
|
diff --git a/apps/web/src/features/workbench/ChannelContent.test.ts b/apps/web/src/features/workbench/ChannelContent.test.ts
|
|
new file mode 100644
|
|
index 0000000..41c4368
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/ChannelContent.test.ts
|
|
@@ -0,0 +1,39 @@
|
|
+import '@testing-library/jest-dom/vitest';
|
|
+import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
+import { describe, expect, it } from 'vitest';
|
|
+import ChannelContent from './ChannelContent.svelte';
|
|
+import { getProjectWorkspace } from './mockData';
|
|
+
|
|
+describe('ChannelContent', () => {
|
|
+ const workspace = getProjectWorkspace(1);
|
|
+
|
|
+ it('renders different templates for system and custom channels', () => {
|
|
+ for (const type of ['overview', 'inbox', 'tasks', 'ai_sessions', 'notes_sources', 'cron', 'custom_link'] as const) {
|
|
+ const channel = workspace.channels.find((item) => item.type === type);
|
|
+ if (!channel) throw new Error(`missing ${type}`);
|
|
+
|
|
+ render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
|
|
+ }
|
|
+
|
|
+ expect(screen.getByText('Open external channel')).toBeInTheDocument();
|
|
+ });
|
|
+
|
|
+ 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');
|
|
+ });
|
|
+});
|
|
diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte
|
|
new file mode 100644
|
|
index 0000000..f36f387
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/ObjectInspector.svelte
|
|
@@ -0,0 +1,37 @@
|
|
+<script lang="ts">
|
|
+ import type { InspectorItem } from './types';
|
|
+
|
|
+ export let item: InspectorItem | null;
|
|
+ let activeTab: 'discussion' | 'properties' | 'more' = 'discussion';
|
|
+</script>
|
|
+
|
|
+<aside class="object-inspector" aria-label="Object inspector">
|
|
+ <div class="inspector-tabs" role="tablist" aria-label="Inspector tabs">
|
|
+ <button role="tab" aria-selected={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button>
|
|
+ <button role="tab" aria-selected={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button>
|
|
+ <button role="tab" aria-selected={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">Copy link</button>
|
|
+ <button type="button">Archive</button>
|
|
+ {/if}
|
|
+ {:else}
|
|
+ <h2>Inspector</h2>
|
|
+ <p>Select an item to inspect discussion, properties, and actions.</p>
|
|
+ {/if}
|
|
+</aside>
|
|
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
|
index 4ab9ffd..3d29c30 100644
|
|
--- a/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
|
+++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
|
|
@@ -1,41 +1,48 @@
|
|
<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 type { InspectorItem } from './types';
|
|
|
|
export let currentUser: { account: string };
|
|
|
|
let selectedProjectID = workbenchProjects[0].id;
|
|
+ let selectedItem: InspectorItem | null = null;
|
|
$: workspace = getProjectWorkspace(selectedProjectID);
|
|
$: selectedChannelID = workspace.channels[0].id;
|
|
+ $: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0];
|
|
|
|
function selectProject(projectID: number) {
|
|
selectedProjectID = projectID;
|
|
}
|
|
|
|
function selectChannel(channelID: string) {
|
|
selectedChannelID = channelID;
|
|
}
|
|
+
|
|
+ function inspectItem(item: InspectorItem) {
|
|
+ selectedItem = item;
|
|
+ }
|
|
</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>
|
|
+ <ChannelContent {workspace} channel={selectedChannel} onInspect={inspectItem} />
|
|
</section>
|
|
- <aside class="object-inspector" aria-label="Object inspector">
|
|
- <p>Select an item to inspect discussion, properties, and actions.</p>
|
|
- </aside>
|
|
+ <ObjectInspector item={selectedItem} />
|
|
</div>
|
|
</main>
|
|
diff --git a/apps/web/src/features/workbench/channels/AISessionsChannel.svelte b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
|
|
new file mode 100644
|
|
index 0000000..3b52383
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte
|
|
@@ -0,0 +1,27 @@
|
|
+<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>
|
|
diff --git a/apps/web/src/features/workbench/channels/CronChannel.svelte b/apps/web/src/features/workbench/channels/CronChannel.svelte
|
|
new file mode 100644
|
|
index 0000000..7a6f2c3
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/channels/CronChannel.svelte
|
|
@@ -0,0 +1,19 @@
|
|
+<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>
|
|
diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
|
|
new file mode 100644
|
|
index 0000000..0fa9111
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte
|
|
@@ -0,0 +1,16 @@
|
|
+<script lang="ts">
|
|
+ import type { WorkbenchChannel } from '../types';
|
|
+
|
|
+ export let channel: WorkbenchChannel;
|
|
+
|
|
+ function copyUrl() {
|
|
+ void navigator.clipboard?.writeText(channel.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 href={channel.url} target="_blank" rel="noreferrer">Open external channel</a>
|
|
+ <button type="button" on:click={copyUrl}>Copy URL</button>
|
|
+</section>
|
|
diff --git a/apps/web/src/features/workbench/channels/InboxChannel.svelte b/apps/web/src/features/workbench/channels/InboxChannel.svelte
|
|
new file mode 100644
|
|
index 0000000..2d25d63
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/channels/InboxChannel.svelte
|
|
@@ -0,0 +1,22 @@
|
|
+<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>
|
|
diff --git a/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
|
|
new file mode 100644
|
|
index 0000000..256ef8b
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte
|
|
@@ -0,0 +1,19 @@
|
|
+<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>
|
|
diff --git a/apps/web/src/features/workbench/channels/OverviewChannel.svelte b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
|
|
new file mode 100644
|
|
index 0000000..6d478cf
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/channels/OverviewChannel.svelte
|
|
@@ -0,0 +1,29 @@
|
|
+<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">
|
|
+ <p>{workspace.project.name}</p>
|
|
+ <h1>Overview</h1>
|
|
+ </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>
|
|
+</section>
|
|
diff --git a/apps/web/src/features/workbench/channels/TasksChannel.svelte b/apps/web/src/features/workbench/channels/TasksChannel.svelte
|
|
new file mode 100644
|
|
index 0000000..b59e47e
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/workbench/channels/TasksChannel.svelte
|
|
@@ -0,0 +1,23 @@
|
|
+<script lang="ts">
|
|
+ import type { InspectorItem, WorkTask } from '../types';
|
|
+
|
|
+ export let tasks: WorkTask[];
|
|
+ export let onInspect: (item: InspectorItem) => 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">
|
|
+ <span class="task-check" aria-hidden="true">{task.completed ? 'Done' : 'Open'}</span>
|
|
+ <div><h2>{task.title}</h2><p>{task.summary}</p><small>{task.owner} · {task.due} · #{task.tag}</small></div>
|
|
+ <button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button>
|
|
+ </article>
|
|
+ {/each}
|
|
+ </div>
|
|
+</section>
|