Compare commits

...

9 Commits

Author SHA1 Message Date
bvandeusen 96a44886ef Merge pull request 'feat(workspace): redesign project workspace with floating chat widget' (#42) from dev into main 2026-04-22 19:11:11 +00:00
bvandeusen f422d4158c style(workspace-notes): larger, more spacious note title
Title input bumps to 1.4rem with Fraunces serif (matches the
design language's heading treatment) and gains 0.9/1.1rem padding
on the title row for breathing room.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 14:52:58 -04:00
bvandeusen 70ec060b7a style(workspace-notes): move active-note indicator to right edge
Anchors it to the editor side rather than the tasks-panel side.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 14:43:59 -04:00
bvandeusen 3b41a45757 style(workspace-notes): drop border between toolbar and body
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 14:35:45 -04:00
bvandeusen 19e1ca505d style(workspace-notes): unify editor chrome, distinguish rail
- Drop border-bottom lines between header / title / tags / toolbar
  so they read as one continuous input surface.
- Add a single border-top above the editor body so the body's
  existing boundary stays clear.
- Notes rail gets --color-bg-card so it no longer visually merges
  with the tasks panel (both previously shared --color-surface).
  Dropped the rail's border-right since the tint now carries the
  division from the editor pane.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 13:30:17 -04:00
bvandeusen f75c40b417 refactor(workspace): match KnowledgeView mini-chat dock style
- Widget is now a bottom-anchored dock spanning the content width
  (max-width 1200px, rounded top corners, bg-secondary, upward shadow)
  instead of a floating corner box.
- Drop the closed state and ✕ button — widget is always present,
  matching KnowledgeView's pattern. Collapsed = input-only dock,
  expanded = full ChatPanel above the input.
- Grid ratio 1fr:2fr → 0.85fr:2.15fr so tasks get a touch less room
  and notes a touch more.
- Workspace note-rail (the notes picker column inside the editor)
  widens from 155px to 200px so note titles breathe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 12:53:02 -04:00
bvandeusen 649c0f124b feat(workspace): history dropdown on project chat widget
Clicking the ▾ button reveals conversations where
rag_project_id === projectId, most-recent first. Selecting one
repoints the widget (and the workspace_conv_{pid} storage key) to
that conversation without deleting the previously active one.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:10:54 -04:00
bvandeusen 6026c24450 refactor(workspace): two-column grid; widget drives tool-call refresh
WorkspaceView now renders tasks (1fr) on the left and notes (2fr) on
the right with the floating WorkspaceChatWidget layered on top. The
widget owns the SSE streamingToolCalls watcher and emits note-changed
and task-changed events; the view listens and reloads the affected
panel refs.

Dropped from the view:
- ChatPanel mount, empty-state quick chips, and Chat header toggle
- The conversation lifecycle + SSE watcher (moved into the widget)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:09:50 -04:00
bvandeusen ae5c61a179 feat(workspace): floating project chat widget
Modeled on KnowledgeView's mini-chat. Three states (closed handle,
collapsed input-only, expanded full panel). Takes over the project
conversation lifecycle from WorkspaceView (resume-or-create on mount,
delete-if-empty-and-new on unmount) and adds a restart action. Reuses
ChatPanel for expanded view and ChatInputBar for collapsed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:08:25 -04:00
3 changed files with 439 additions and 211 deletions
@@ -0,0 +1,390 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
import { useChatStore } from '@/stores/chat';
import ChatPanel from '@/components/ChatPanel.vue';
import ChatInputBar from '@/components/ChatInputBar.vue';
const props = defineProps<{ projectId: number }>();
const emit = defineEmits<{
'note-changed': [noteId: number];
'task-changed': [];
}>();
const chatStore = useChatStore();
// SSE watcher — emit refresh events when tool calls succeed during streaming
const processedCount = ref(0);
watch(
() => chatStore.streamingToolCalls,
(calls) => {
for (let i = processedCount.value; i < calls.length; i++) {
const tc = calls[i];
if (tc.status !== 'success') continue;
// Note-editor refresh: any successful create_note/update_note
if (['create_note', 'update_note'].includes(tc.function) && tc.result?.data?.id) {
emit('note-changed', tc.result.data.id as number);
}
// Task-panel refresh: milestone changes, or note changes where the note is a task (has status)
const isTaskNoteChange =
['create_note', 'update_note'].includes(tc.function) &&
tc.result?.data?.status !== undefined;
const isMilestoneChange = ['create_milestone', 'update_milestone'].includes(tc.function);
if (isTaskNoteChange || isMilestoneChange) {
emit('task-changed');
}
}
processedCount.value = calls.length;
},
{ deep: true }
);
watch(
() => chatStore.streaming,
(s) => {
if (!s) processedCount.value = 0;
}
);
type WidgetState = 'collapsed' | 'expanded';
const widgetState = ref<WidgetState>('collapsed');
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
const inputBarRef = ref<InstanceType<typeof ChatInputBar> | null>(null);
const workspaceConvId = ref<number | null>(null);
let isNewConv = false;
const historyOpen = ref(false);
const projectConversations = computed(() =>
chatStore.conversations
.filter((c) => c.rag_project_id === props.projectId)
.slice()
.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''))
);
async function pickConversation(convId: number) {
historyOpen.value = false;
if (convId === workspaceConvId.value) return;
await chatStore.fetchConversation(convId);
workspaceConvId.value = convId;
// The picked conversation already exists on the server; not ours to clean up.
isNewConv = false;
localStorage.setItem(_storageKey(props.projectId), String(convId));
}
function toggleHistory() {
historyOpen.value = !historyOpen.value;
}
function conversationLabel(c: { id: number; title?: string | null }): string {
return (c.title && c.title.trim()) || `Conversation #${c.id}`;
}
function _storageKey(pid: number) {
return `workspace_conv_${pid}`;
}
function expand() {
widgetState.value = 'expanded';
nextTick(() => chatPanelRef.value?.focus());
}
function collapse() {
widgetState.value = 'collapsed';
nextTick(() => inputBarRef.value?.focus());
}
async function restart() {
const conv = await chatStore.createConversation();
workspaceConvId.value = conv.id;
isNewConv = true;
localStorage.setItem(_storageKey(props.projectId), String(conv.id));
await chatStore.fetchConversation(conv.id);
}
/** Auto-expand and send — used when user submits from collapsed input. */
async function onCollapsedSubmit(payload: { content: string; contextNoteId?: number }) {
widgetState.value = 'expanded';
await chatStore.sendMessage(payload.content, payload.contextNoteId, undefined, false);
}
function prefill(text: string) {
if (widgetState.value === 'expanded') {
chatPanelRef.value?.prefill(text);
} else {
inputBarRef.value?.prefill(text);
}
}
onMounted(async () => {
if (chatStore.conversations.length === 0) {
await chatStore.fetchConversations();
}
const key = _storageKey(props.projectId);
const storedId = localStorage.getItem(key);
if (storedId) {
const existingId = Number(storedId);
try {
await chatStore.fetchConversation(existingId);
workspaceConvId.value = existingId;
isNewConv = false;
chatStore.reconnectIfGenerating(existingId);
} catch {
localStorage.removeItem(key);
}
}
if (workspaceConvId.value === null) {
const conv = await chatStore.createConversation();
workspaceConvId.value = conv.id;
isNewConv = true;
await chatStore.fetchConversation(conv.id);
localStorage.setItem(key, String(conv.id));
}
});
onUnmounted(async () => {
if (workspaceConvId.value !== null && isNewConv) {
const id = workspaceConvId.value;
const conv = chatStore.conversations.find((c) => c.id === id);
if (conv && conv.message_count === 0) {
await chatStore.deleteConversation(id);
localStorage.removeItem(_storageKey(props.projectId));
}
}
});
defineExpose({ prefill });
</script>
<template>
<!-- Bottom-anchored chat dock: collapsed (input only) or expanded (full panel) -->
<div
class="ws-chat-widget"
:class="{ 'ws-chat-widget--expanded': widgetState === 'expanded' }"
>
<!-- Header (expanded only) -->
<div v-if="widgetState === 'expanded'" class="ws-chat-header">
<span class="ws-chat-title">Project chat</span>
<div class="ws-chat-header-actions">
<div class="ws-chat-history-wrap">
<button
class="btn-icon-sm"
:class="{ active: historyOpen }"
title="History"
@click="toggleHistory"
></button>
<div v-if="historyOpen" class="ws-chat-history-menu">
<div v-if="projectConversations.length === 0" class="ws-chat-history-empty">
No past conversations
</div>
<button
v-for="c in projectConversations"
:key="c.id"
class="ws-chat-history-item"
:class="{ current: c.id === workspaceConvId }"
@click="pickConversation(c.id)"
>
<span class="ws-chat-history-title">{{ conversationLabel(c) }}</span>
</button>
</div>
</div>
<button class="btn-icon-sm" title="Restart conversation" @click="restart"></button>
<button class="btn-icon-sm" title="Collapse" @click="collapse">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
</div>
<!-- Expanded body: full ChatPanel (includes its own input bar) -->
<div v-if="widgetState === 'expanded'" class="ws-chat-body">
<ChatPanel
ref="chatPanelRef"
variant="full"
:projectId="projectId"
placeholder="Message the agent…"
/>
</div>
<!-- Collapsed: standalone input bar with expand affordance -->
<div v-else class="ws-chat-collapsed">
<button class="ws-chat-expand-btn" title="Expand chat" @click="expand">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="18 15 12 9 6 15"/>
</svg>
</button>
<div class="ws-chat-input-row">
<ChatInputBar
ref="inputBarRef"
placeholder="Message the agent…"
@submit="onCollapsedSubmit"
@abort="chatStore.cancelGeneration()"
/>
</div>
</div>
</div>
</template>
<style scoped>
.ws-chat-widget {
position: absolute;
bottom: 0;
left: 0;
right: 0;
max-width: var(--page-max-width);
margin-inline: auto;
z-index: 20;
display: flex;
flex-direction: column;
background: var(--color-bg-secondary);
border-radius: 16px 16px 0 0;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.35), 0 -1px 0 rgba(255, 255, 255, 0.05);
overflow: hidden;
transition: height 0.2s ease;
}
.ws-chat-widget--expanded {
height: 500px;
}
.ws-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.ws-chat-title {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.ws-chat-header-actions {
display: flex;
gap: 4px;
align-items: center;
}
.btn-icon-sm {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 2px 6px;
font-size: 0.9rem;
line-height: 1;
border-radius: 4px;
}
.btn-icon-sm:hover:not(:disabled) {
color: var(--color-text);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.btn-icon-sm:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-icon-sm.active {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.ws-chat-history-wrap {
position: relative;
}
.ws-chat-history-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 220px;
max-height: 260px;
overflow-y: auto;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 30;
padding: 4px;
}
.ws-chat-history-empty {
padding: 10px 12px;
font-size: 0.82rem;
color: var(--color-text-muted);
text-align: center;
}
.ws-chat-history-item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
padding: 8px 10px;
border-radius: 6px;
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
}
.ws-chat-history-item:hover {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.ws-chat-history-item.current {
color: var(--color-primary);
font-weight: 600;
}
.ws-chat-history-title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-chat-body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.ws-chat-body :deep(.chat-body) {
flex: 1;
min-height: 0;
}
.ws-chat-collapsed {
display: flex;
align-items: stretch;
}
.ws-chat-expand-btn {
flex-shrink: 0;
padding: 0 10px;
background: none;
border: none;
border-right: 1px solid var(--color-border);
color: var(--color-text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.ws-chat-expand-btn:hover {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.ws-chat-input-row {
flex: 1;
min-width: 0;
padding: 10px 12px;
flex-shrink: 0;
}
</style>
+13 -12
View File
@@ -457,12 +457,12 @@ defineExpose({ reload: loadProjectNotes });
/* ── Left rail ── */
.note-rail {
width: 155px;
width: 200px;
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--color-border);
background: var(--color-bg-card, var(--color-bg-secondary));
}
.rail-header {
@@ -551,13 +551,13 @@ defineExpose({ reload: loadProjectNotes });
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
cursor: pointer;
gap: 0.15rem;
border-left: 2px solid transparent;
border-right: 2px solid transparent;
transition: background 0.12s;
}
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
.note-row.active {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
border-left-color: var(--color-primary);
border-right-color: var(--color-primary);
}
.note-row-main {
@@ -715,7 +715,6 @@ defineExpose({ reload: loadProjectNotes });
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
gap: 0.5rem;
}
@@ -742,8 +741,7 @@ defineExpose({ reload: loadProjectNotes });
.btn-save:disabled { opacity: 0.4; cursor: default; }
.note-title-row {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
padding: 0.9rem 1.1rem 0.5rem;
flex-shrink: 0;
}
@@ -751,19 +749,25 @@ defineExpose({ reload: loadProjectNotes });
width: 100%;
background: transparent;
border: none;
font-size: 1rem;
font-size: 1.4rem;
font-weight: 600;
line-height: 1.25;
color: var(--color-text);
padding: 0;
font-family: 'Fraunces', serif;
letter-spacing: -0.01em;
}
.note-title-input:focus { outline: none; }
.note-title-input::placeholder {
color: var(--color-text-muted);
font-style: italic;
}
.tag-row {
display: flex;
align-items: flex-start;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.tag-row > :first-child { flex: 1; min-width: 0; }
@@ -789,7 +793,6 @@ defineExpose({ reload: loadProjectNotes });
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
border-bottom: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
flex-shrink: 0;
}
@@ -824,7 +827,6 @@ defineExpose({ reload: loadProjectNotes });
.toolbar-row {
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
@@ -834,7 +836,6 @@ defineExpose({ reload: loadProjectNotes });
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
flex-shrink: 0;
}
+36 -199
View File
@@ -1,15 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { ref, computed, onMounted } from "vue";
import { useRoute } from "vue-router";
import { apiGet } from "@/api/client";
import { useChatStore } from "@/stores/chat";
import { useToastStore } from "@/stores/toast";
import ChatPanel from "@/components/ChatPanel.vue";
import WorkspaceTaskPanel from "@/components/WorkspaceTaskPanel.vue";
import WorkspaceNoteEditor from "@/components/WorkspaceNoteEditor.vue";
import WorkspaceChatWidget from "@/components/WorkspaceChatWidget.vue";
const route = useRoute();
const chatStore = useChatStore();
const toast = useToastStore();
const projectId = computed(() => Number(route.params.projectId));
@@ -21,142 +19,62 @@ interface Project {
}
const project = ref<Project | null>(null);
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null);
const activeNoteId = ref<number | null>(null);
let workspaceConvId: number | null = null;
let isNewConv = false;
function _storageKey(pid: number) {
return `workspace_conv_${pid}`;
}
// Panel collapse state — persisted per project
// Panel collapse state — persisted per project (tasks + notes only; chat is now a widget)
function _panelKey(pid: number) { return `workspace_panels_${pid}`; }
function _loadPanelState(pid: number) {
try {
const raw = localStorage.getItem(_panelKey(pid));
if (raw) {
const saved = JSON.parse(raw);
// Ensure at least one panel is open after restore
if (!saved.tasks && !saved.chat && !saved.notes) saved.chat = true;
return saved as { tasks: boolean; chat: boolean; notes: boolean };
const saved = JSON.parse(raw) as Partial<{ tasks: boolean; notes: boolean }>;
const tasks = saved.tasks ?? true;
const notes = saved.notes ?? true;
// Guard: at least one panel must be open
if (!tasks && !notes) return { tasks: true, notes: true };
return { tasks, notes };
}
} catch { /* ignore */ }
return { tasks: true, chat: true, notes: true };
return { tasks: true, notes: true };
}
const panelOpen = ref({ tasks: true, chat: true, notes: true });
const panelOpen = ref<{ tasks: boolean; notes: boolean }>({ tasks: true, notes: true });
const gridColumns = computed(() => {
return [
panelOpen.value.tasks ? "minmax(0, 0.8fr)" : "0px",
panelOpen.value.chat ? "minmax(0, 1.1fr)" : "0px",
panelOpen.value.notes ? "minmax(0, 1.1fr)" : "0px",
panelOpen.value.tasks ? "minmax(0, 0.85fr)" : "0px",
panelOpen.value.notes ? "minmax(0, 2.15fr)" : "0px",
].join(" ");
});
// SSE watcher — auto-load notes/tasks when tool calls succeed
const processedCount = ref(0);
watch(
() => chatStore.streamingToolCalls,
(calls) => {
for (let i = processedCount.value; i < calls.length; i++) {
const tc = calls[i];
if (
["create_note", "update_note"].includes(tc.function) &&
tc.status === "success" &&
tc.result?.data?.id
) {
activeNoteId.value = tc.result.data.id as number;
noteEditorRef.value?.reload();
}
if (
tc.status === "success" && (
["create_milestone", "update_milestone"].includes(tc.function) ||
(tc.function === "create_note" && tc.result?.data?.status) ||
(tc.function === "update_note" && tc.result?.data?.status !== undefined)
)
) {
taskPanelRef.value?.reload();
}
}
processedCount.value = calls.length;
},
{ deep: true }
);
watch(
() => chatStore.streaming,
(s) => {
if (!s) processedCount.value = 0;
}
);
function togglePanel(panel: keyof typeof panelOpen.value) {
const open = panelOpen.value;
const openCount = [open.tasks, open.chat, open.notes].filter(Boolean).length;
const openCount = [open.tasks, open.notes].filter(Boolean).length;
if (open[panel] && openCount <= 1) return;
panelOpen.value[panel] = !panelOpen.value[panel];
localStorage.setItem(_panelKey(projectId.value), JSON.stringify(panelOpen.value));
}
function prefill(text: string) {
chatPanelRef.value?.prefill(text);
function onNoteChanged(noteId: number) {
activeNoteId.value = noteId;
noteEditorRef.value?.reload();
}
function onTaskChanged() {
taskPanelRef.value?.reload();
}
onMounted(async () => {
// Restore panel state
panelOpen.value = _loadPanelState(projectId.value);
// Load project info
try {
project.value = await apiGet<Project>(`/api/projects/${projectId.value}`);
} catch {
toast.show("Failed to load project", "error");
}
const key = _storageKey(projectId.value);
const storedId = localStorage.getItem(key);
if (storedId) {
// Try to reuse the existing workspace conversation
const existingId = Number(storedId);
try {
await chatStore.fetchConversation(existingId);
workspaceConvId = existingId;
isNewConv = false;
chatStore.reconnectIfGenerating(existingId);
} catch {
// Conversation was deleted — create a fresh one
localStorage.removeItem(key);
}
}
if (workspaceConvId === null) {
// Create a new workspace conversation and persist its ID
const conv = await chatStore.createConversation();
workspaceConvId = conv.id;
isNewConv = true;
await chatStore.fetchConversation(conv.id);
localStorage.setItem(key, String(conv.id));
}
nextTick(() => chatPanelRef.value?.focus());
});
onUnmounted(async () => {
// Only delete if we created a brand-new conversation this session and it's still empty
if (workspaceConvId !== null && isNewConv) {
const conv = chatStore.conversations.find((c) => c.id === workspaceConvId);
if (conv && conv.message_count === 0) {
await chatStore.deleteConversation(workspaceConvId);
localStorage.removeItem(_storageKey(projectId.value));
}
}
});
</script>
@@ -176,13 +94,6 @@ onUnmounted(async () => {
>
Tasks
</button>
<button
:class="['panel-toggle', { active: panelOpen.chat }]"
title="Toggle Chat panel"
@click="togglePanel('chat')"
>
Chat
</button>
<button
:class="['panel-toggle', { active: panelOpen.notes }]"
title="Toggle Notes panel"
@@ -193,9 +104,8 @@ onUnmounted(async () => {
</div>
</header>
<!-- Three-panel body -->
<!-- Two-panel body -->
<div class="ws-body" :style="{ gridTemplateColumns: gridColumns }">
<!-- Left: Tasks -->
<div v-show="panelOpen.tasks" class="ws-panel">
<Transition name="panel-fade">
@@ -209,35 +119,8 @@ onUnmounted(async () => {
</Transition>
</div>
<!-- Center: Chat -->
<div v-show="panelOpen.chat" class="ws-panel ws-panel-chat">
<Transition name="panel-fade">
<div v-if="panelOpen.chat" class="panel-inner panel-inner-chat">
<!-- Quick chips (shown when conversation is empty) -->
<div
v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
class="empty-chat-prompt"
>
<p class="empty-hint">What would you like to work on?</p>
<div class="quick-chips">
<button class="quick-chip" @click="prefill('Summarize the current status of this project')">📊 Project status</button>
<button class="quick-chip" @click="prefill('Create a note about ')">📝 New note</button>
<button class="quick-chip" @click="prefill('Add tasks for ')"> Add tasks</button>
</div>
</div>
<ChatPanel
ref="chatPanelRef"
variant="full"
:projectId="projectId"
placeholder="Message the agent… (Enter to send)"
class="ws-chat-panel"
/>
</div>
</Transition>
</div>
<!-- Right: Note Editor -->
<div v-show="panelOpen.notes" class="ws-panel">
<div v-show="panelOpen.notes" class="ws-panel ws-panel-notes">
<Transition name="panel-fade">
<div v-if="panelOpen.notes" class="panel-inner">
<WorkspaceNoteEditor
@@ -249,13 +132,21 @@ onUnmounted(async () => {
</div>
</Transition>
</div>
</div>
<!-- Floating chat widget -->
<WorkspaceChatWidget
v-if="project"
:project-id="projectId"
@note-changed="onNoteChanged"
@task-changed="onTaskChanged"
/>
</div>
</template>
<style scoped>
.workspace-root {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
@@ -263,7 +154,6 @@ onUnmounted(async () => {
background: var(--color-bg);
}
/* Header */
.ws-header {
display: flex;
align-items: center;
@@ -318,7 +208,6 @@ onUnmounted(async () => {
color: var(--color-primary);
}
/* Three-panel layout */
.ws-body {
display: grid;
flex: 1;
@@ -331,6 +220,10 @@ onUnmounted(async () => {
min-width: 0;
}
.ws-panel-notes {
border-left: 1px solid var(--color-border);
}
.panel-inner {
height: 100%;
display: flex;
@@ -345,60 +238,4 @@ onUnmounted(async () => {
.panel-fade-leave-to {
opacity: 0;
}
/* Chat panel */
.ws-panel-chat {
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
}
.panel-inner-chat {
position: relative;
}
.ws-chat-panel {
flex: 1;
min-height: 0;
}
.empty-chat-prompt {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
padding: 2rem 1rem;
pointer-events: none;
z-index: 1;
}
.empty-hint {
margin: 0 0 1rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.quick-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
pointer-events: auto;
}
.quick-chip {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 0.4rem 0.85rem;
font-size: 0.82rem;
color: var(--color-text-secondary);
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
font-family: inherit;
}
.quick-chip:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
}
</style>