Add ContentLayout with auto-direction + Dashboard/Files/Artifacts panes

- ContentLayout: ResizeObserver-based row/col direction (flips at 1.5× aspect ratio),
  flex weights 40/40/10 for chat/dashboard/thin, files+artifacts share thin slot
- DashboardPane: placeholder page (agent artifacts will render here)
- FilesPane / ArtifactsPane: dummy panels with vertical/horizontal orientation toggle
- usePanels: replace 'workspace' PaneId with 'dashboard' | 'files' | 'artifacts'
- AgentsView: wire ContentLayout, remove WorkspacePane, clean up pane-layout styles
- AppToolbar: replace workspace toggle with dashboard/files/artifacts buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nico 2026-04-02 23:00:49 +02:00
parent 4607a73d67
commit 60029f1863
7 changed files with 455 additions and 43 deletions

View File

@ -8,7 +8,7 @@
<div class="toolbar-spacer" /> <div class="toolbar-spacer" />
<!-- Pane toggles (chat, workspace) --> <!-- Pane toggles -->
<button <button
class="toolbar-pill toolbar-pill-icon" class="toolbar-pill toolbar-pill-icon"
:class="{ active: isPaneOpen('chat') }" :class="{ active: isPaneOpen('chat') }"
@ -19,11 +19,27 @@
</button> </button>
<button <button
class="toolbar-pill toolbar-pill-icon" class="toolbar-pill toolbar-pill-icon"
:class="{ active: isPaneOpen('workspace') }" :class="{ active: isPaneOpen('dashboard') }"
@click="togglePane('workspace')" @click="togglePane('dashboard')"
title="Workspace" title="Dashboard"
> >
<RectangleStackIcon class="w-4 h-4" /> <RectangleGroupIcon class="w-4 h-4" />
</button>
<button
class="toolbar-pill toolbar-pill-icon"
:class="{ active: isPaneOpen('files') }"
@click="togglePane('files')"
title="Files"
>
<FolderIcon class="w-4 h-4" />
</button>
<button
class="toolbar-pill toolbar-pill-icon"
:class="{ active: isPaneOpen('artifacts') }"
@click="togglePane('artifacts')"
title="Artifacts"
>
<SparklesIcon class="w-4 h-4" />
</button> </button>
<div class="toolbar-divider" /> <div class="toolbar-divider" />
@ -102,7 +118,7 @@
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: 'AppToolbar' }); defineOptions({ name: 'AppToolbar' });
import { ref, computed, type Component } from 'vue'; import { ref, computed, type Component } from 'vue';
import { WifiIcon, SignalIcon, QueueListIcon, CpuChipIcon, EyeIcon, CircleStackIcon, ChatBubbleLeftIcon, RectangleStackIcon } from '@heroicons/vue/20/solid'; import { WifiIcon, SignalIcon, QueueListIcon, CpuChipIcon, EyeIcon, CircleStackIcon, ChatBubbleLeftIcon, RectangleGroupIcon, FolderIcon, SparklesIcon } from '@heroicons/vue/20/solid';
import { THEME_ICONS, THEME_NAMES, useTheme, type Theme } from '../composables/useTheme'; import { THEME_ICONS, THEME_NAMES, useTheme, type Theme } from '../composables/useTheme';
import { ws, auth, agents, takeover } from '../store'; import { ws, auth, agents, takeover } from '../store';
import { useChatStore } from '../store/chat'; import { useChatStore } from '../store/chat';

View File

@ -0,0 +1,115 @@
<template>
<div class="artifacts-pane" :class="orientation">
<div class="artifacts-bar">
<span class="artifacts-label">Artifacts</span>
<button class="orient-btn" @click="toggleOrientation" :title="orientation === 'vertical' ? 'Switch to horizontal' : 'Switch to vertical'">
<component :is="orientation === 'vertical' ? Bars3Icon : Bars3BottomLeftIcon" class="w-3.5 h-3.5" />
</button>
</div>
<div class="artifacts-body">
<span class="artifacts-hint">saved artifacts</span>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtifactsPane' });
import { ref } from 'vue';
import { Bars3Icon, Bars3BottomLeftIcon } from '@heroicons/vue/20/solid';
const orientation = ref<'vertical' | 'horizontal'>('vertical');
function toggleOrientation() {
orientation.value = orientation.value === 'vertical' ? 'horizontal' : 'vertical';
}
</script>
<style scoped>
.artifacts-pane {
display: flex;
height: 100%;
min-height: 0;
min-width: 0;
background: var(--bg);
}
/* Vertical: bar on top, body below */
.artifacts-pane.vertical {
flex-direction: column;
}
.artifacts-pane.vertical .artifacts-bar {
flex-direction: row;
border-bottom: 1px solid var(--border);
padding: 0 8px;
height: 32px;
}
.artifacts-pane.vertical .artifacts-body {
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* Horizontal: bar on left, body to the right */
.artifacts-pane.horizontal {
flex-direction: row;
}
.artifacts-pane.horizontal .artifacts-bar {
flex-direction: column;
border-right: 1px solid var(--border);
padding: 8px 0;
width: 32px;
}
.artifacts-pane.horizontal .artifacts-body {
flex: 1;
flex-direction: row;
align-items: center;
justify-content: center;
}
.artifacts-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
flex-shrink: 0;
background: var(--bg-dim, var(--bg));
}
.artifacts-label {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
opacity: 0.5;
writing-mode: initial;
}
.artifacts-pane.horizontal .artifacts-label {
writing-mode: vertical-rl;
transform: rotate(180deg);
}
.orient-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
background: none;
color: var(--text-dim);
cursor: pointer;
border-radius: 4px;
opacity: 0.5;
transition: opacity 0.12s;
flex-shrink: 0;
}
.orient-btn:hover { opacity: 1; }
.artifacts-body {
display: flex;
opacity: 0.25;
color: var(--text-dim);
font-size: 0.65rem;
}
</style>

View File

@ -0,0 +1,111 @@
<template>
<div class="content-layout" :class="direction" ref="containerRef">
<div v-if="isChatOpen" class="slot slot-chat">
<slot name="chat" />
</div>
<div v-if="isDashboardOpen" class="slot slot-dashboard">
<slot name="dashboard" />
</div>
<!-- Files + Artifacts share one "thin" wrapper.
In row mode: thin is a vertical strip on the right.
In col mode: thin is a horizontal bar at the bottom. -->
<div v-if="isThinVisible" class="slot slot-thin">
<div v-if="isFilesOpen" class="slot slot-files">
<slot name="files" />
</div>
<div v-if="isArtifactsOpen" class="slot slot-artifacts">
<slot name="artifacts" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ContentLayout' });
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { usePanels } from '../composables/usePanels';
const { isChatOpen, isDashboardOpen, isFilesOpen, isArtifactsOpen } = usePanels();
const isThinVisible = computed(() => isFilesOpen.value || isArtifactsOpen.value);
// --- Direction: row when landscape, col when portrait-ish ---
const containerRef = ref<HTMLElement | null>(null);
const direction = ref<'row' | 'col'>('row');
let ro: ResizeObserver | null = null;
function onResize(entries: ResizeObserverEntry[]) {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
// Flip to col when width is not at least 1.5× height
direction.value = width >= height * 1.5 ? 'row' : 'col';
}
onMounted(() => {
if (!containerRef.value) return;
ro = new ResizeObserver(onResize);
ro.observe(containerRef.value);
});
onBeforeUnmount(() => {
ro?.disconnect();
});
</script>
<style scoped>
.content-layout {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
/* ── Slots ─────────────────────────────────────────────── */
.slot {
min-width: 0;
min-height: 0;
overflow: hidden;
position: relative;
}
/* ── Row mode: horizontal distribution ─────────────────── */
.content-layout.row {
flex-direction: row;
}
.content-layout.row .slot-chat { flex: 40 1 0; border-right: 1px solid var(--border); }
.content-layout.row .slot-dashboard { flex: 40 1 0; border-right: 1px solid var(--border); }
.content-layout.row .slot-thin { flex: 10 1 0; display: flex; flex-direction: column; }
.content-layout.row .slot-files { flex: 1 1 0; border-bottom: 1px solid var(--border); }
.content-layout.row .slot-artifacts { flex: 1 1 0; }
/* Remove trailing border on last visible slot in row */
.content-layout.row .slot-thin:last-child,
.content-layout.row .slot-dashboard:last-child,
.content-layout.row .slot-chat:last-child {
border-right: none;
}
/* ── Col mode: vertical distribution ───────────────────── */
.content-layout.col {
flex-direction: column;
}
.content-layout.col .slot-chat { flex: 40 1 0; border-bottom: 1px solid var(--border); }
.content-layout.col .slot-dashboard { flex: 40 1 0; border-bottom: 1px solid var(--border); }
.content-layout.col .slot-thin { flex: 10 1 0; display: flex; flex-direction: row; }
.content-layout.col .slot-files { flex: 1 1 0; border-right: 1px solid var(--border); }
.content-layout.col .slot-artifacts { flex: 1 1 0; }
/* Remove trailing border on last visible slot in col */
.content-layout.col .slot-thin:last-child,
.content-layout.col .slot-dashboard:last-child,
.content-layout.col .slot-chat:last-child {
border-bottom: none;
}
</style>

View File

@ -0,0 +1,44 @@
<template>
<div class="dashboard-pane">
<div class="dashboard-empty">
<span class="dashboard-label">Dashboard</span>
<span class="dashboard-hint">Artifacts and controls from the agent appear here</span>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'DashboardPane' });
</script>
<style scoped>
.dashboard-pane {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow-y: auto;
background: var(--bg);
}
.dashboard-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
color: var(--text-dim);
opacity: 0.4;
}
.dashboard-label {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dashboard-hint {
font-size: 0.72rem;
text-align: center;
max-width: 160px;
}
</style>

View File

@ -0,0 +1,115 @@
<template>
<div class="files-pane" :class="orientation">
<div class="files-bar">
<span class="files-label">Files</span>
<button class="orient-btn" @click="toggleOrientation" :title="orientation === 'vertical' ? 'Switch to horizontal' : 'Switch to vertical'">
<component :is="orientation === 'vertical' ? Bars3Icon : Bars3BottomLeftIcon" class="w-3.5 h-3.5" />
</button>
</div>
<div class="files-body">
<span class="files-hint">file tree</span>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'FilesPane' });
import { ref } from 'vue';
import { Bars3Icon, Bars3BottomLeftIcon } from '@heroicons/vue/20/solid';
const orientation = ref<'vertical' | 'horizontal'>('vertical');
function toggleOrientation() {
orientation.value = orientation.value === 'vertical' ? 'horizontal' : 'vertical';
}
</script>
<style scoped>
.files-pane {
display: flex;
height: 100%;
min-height: 0;
min-width: 0;
background: var(--bg);
}
/* Vertical: bar on top, body below */
.files-pane.vertical {
flex-direction: column;
}
.files-pane.vertical .files-bar {
flex-direction: row;
border-bottom: 1px solid var(--border);
padding: 0 8px;
height: 32px;
}
.files-pane.vertical .files-body {
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* Horizontal: bar on left, body to the right */
.files-pane.horizontal {
flex-direction: row;
}
.files-pane.horizontal .files-bar {
flex-direction: column;
border-right: 1px solid var(--border);
padding: 8px 0;
width: 32px;
}
.files-pane.horizontal .files-body {
flex: 1;
flex-direction: row;
align-items: center;
justify-content: center;
}
.files-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
flex-shrink: 0;
background: var(--bg-dim, var(--bg));
}
.files-label {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
opacity: 0.5;
writing-mode: initial;
}
.files-pane.horizontal .files-label {
writing-mode: vertical-rl;
transform: rotate(180deg);
}
.orient-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
background: none;
color: var(--text-dim);
cursor: pointer;
border-radius: 4px;
opacity: 0.5;
transition: opacity 0.12s;
flex-shrink: 0;
}
.orient-btn:hover { opacity: 1; }
.files-body {
display: flex;
opacity: 0.25;
color: var(--text-dim);
font-size: 0.65rem;
}
</style>

View File

@ -15,7 +15,7 @@ import { ref, computed, type Component } from 'vue';
import tenant from '../tenant'; import tenant from '../tenant';
// Top-level panes (columns) // Top-level panes (columns)
export type PaneId = 'chat' | 'workspace'; export type PaneId = 'chat' | 'dashboard' | 'files' | 'artifacts';
// Debug panels (stacked inside the debug column) // Debug panels (stacked inside the debug column)
export type PanelId = 'graph' | 'trace' | 'nodes' | 'awareness'; export type PanelId = 'graph' | 'trace' | 'nodes' | 'awareness';
export type PanelLocation = 'side' | 'bottom'; export type PanelLocation = 'side' | 'bottom';
@ -77,7 +77,7 @@ function saveSet(key: string, ids: Set<string>) {
// Module-level state (survives HMR) // Module-level state (survives HMR)
const openPanelIds = ref<Set<PanelId>>(loadSet<PanelId>(PANELS_KEY, [])); const openPanelIds = ref<Set<PanelId>>(loadSet<PanelId>(PANELS_KEY, []));
const openPaneIds = ref<Set<PaneId>>(loadSet<PaneId>(PANES_KEY, ['chat'])); const openPaneIds = ref<Set<PaneId>>(loadSet<PaneId>(PANES_KEY, ['chat', 'dashboard']));
export function usePanels() { export function usePanels() {
// Current role // Current role
@ -108,8 +108,10 @@ export function usePanels() {
const hasDebugColumn = hasSidePanels; const hasDebugColumn = hasSidePanels;
// Pane state // Pane state
const isChatOpen = computed(() => openPaneIds.value.has('chat')); const isChatOpen = computed(() => openPaneIds.value.has('chat'));
const isWorkspaceOpen = computed(() => openPaneIds.value.has('workspace')); const isDashboardOpen = computed(() => openPaneIds.value.has('dashboard'));
const isFilesOpen = computed(() => openPaneIds.value.has('files'));
const isArtifactsOpen = computed(() => openPaneIds.value.has('artifacts'));
function togglePanel(id: PanelId) { function togglePanel(id: PanelId) {
const next = new Set(openPanelIds.value); const next = new Set(openPanelIds.value);
@ -158,7 +160,9 @@ export function usePanels() {
openPanelIds, openPanelIds,
// Top-level panes // Top-level panes
isChatOpen, isChatOpen,
isWorkspaceOpen, isDashboardOpen,
isFilesOpen,
isArtifactsOpen,
togglePane, togglePane,
isPaneOpen, isPaneOpen,
openPaneIds, openPaneIds,

View File

@ -24,33 +24,39 @@
</div> </div>
</div> </div>
<!-- 3-column layout: chat | workspace | debug --> <!-- Main layout: content unit + debug column -->
<div v-show="!showPicker" class="pane-layout"> <div v-show="!showPicker" class="pane-layout">
<!-- Chat column -->
<ChatPane
v-if="isChatOpen"
ref="chatPaneRef"
class="pane-column"
:connected="connected"
:selectedAgent="selectedAgent"
:selectedMode="selectedMode"
:allAgents="allAgents"
:defaultAgent="defaultAgent || ''"
:wsSend="wsSend"
:viewActive="viewActive"
@hud-update="onHudUpdate"
/>
<!-- Workspace column --> <!-- Content unit: Chat · Dashboard · Files · Artifacts -->
<WorkspacePane <ContentLayout class="pane-content">
v-if="isWorkspaceOpen" <template #chat>
class="pane-column" <ChatPane
/> ref="chatPaneRef"
:connected="connected"
:selectedAgent="selectedAgent"
:selectedMode="selectedMode"
:allAgents="allAgents"
:defaultAgent="defaultAgent || ''"
:wsSend="wsSend"
:viewActive="viewActive"
@hud-update="onHudUpdate"
/>
</template>
<template #dashboard>
<DashboardPane />
</template>
<template #files>
<FilesPane />
</template>
<template #artifacts>
<ArtifactsPane />
</template>
</ContentLayout>
<!-- Debug column (stacked panels) --> <!-- Debug column (stacked panels, fixed width) -->
<DebugColumn <DebugColumn
v-if="hasDebugColumn" v-if="hasDebugColumn"
class="pane-column pane-debug" class="pane-debug"
:hudTree="hudState.hudTree" :hudTree="hudState.hudTree"
:hudVersion="hudState.hudVersion" :hudVersion="hudState.hudVersion"
:connected="hudState.connected" :connected="hudState.connected"
@ -72,7 +78,10 @@ import { ws, auth, agents } from '../store';
import { usePanels } from '../composables/usePanels'; import { usePanels } from '../composables/usePanels';
import { LockClosedIcon, UserGroupIcon } from '@heroicons/vue/20/solid'; import { LockClosedIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import ChatPane from '../components/ChatPane.vue'; import ChatPane from '../components/ChatPane.vue';
import WorkspacePane from '../components/WorkspacePane.vue'; import ContentLayout from '../components/ContentLayout.vue';
import DashboardPane from '../components/DashboardPane.vue';
import FilesPane from '../components/FilesPane.vue';
import ArtifactsPane from '../components/ArtifactsPane.vue';
import DebugColumn from '../components/DebugColumn.vue'; import DebugColumn from '../components/DebugColumn.vue';
import router from '../router'; import router from '../router';
@ -80,7 +89,7 @@ import router from '../router';
const agentsRoute = useRoute(); const agentsRoute = useRoute();
// Pane visibility // Pane visibility
const { isChatOpen, isWorkspaceOpen, hasDebugColumn } = usePanels(); const { hasDebugColumn } = usePanels();
// Auth + agents // Auth + agents
const { connected, send: wsSend } = ws; const { connected, send: wsSend } = ws;
@ -146,29 +155,27 @@ watch([selectedAgent, selectedMode], ([agent, mode], [oldAgent, oldMode]) => {
</script> </script>
<style scoped> <style scoped>
/* 3-column pane layout */ /* Outer layout: content unit + debug column */
.pane-layout { .pane-layout {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: row;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.pane-column { /* Content unit fills all remaining space */
.pane-content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
border-left: 1px solid var(--border);
overflow: hidden;
}
.pane-column:first-child {
border-left: none;
} }
/* Debug column: fixed width */ /* Debug column: fixed width, separated by border */
.pane-debug { .pane-debug {
flex: 0 0 320px; flex: 0 0 320px;
max-width: 400px; max-width: 400px;
border-left: 1px solid var(--border);
} }
/* Agent picker (no agent selected) */ /* Agent picker (no agent selected) */