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>
This commit is contained in:
2026-04-22 11:09:50 -04:00
parent ae5c61a179
commit 6026c24450
2 changed files with 78 additions and 200 deletions
@@ -1,13 +1,54 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue'; import { ref, nextTick, onMounted, onUnmounted, watch } from 'vue';
import { useChatStore } from '@/stores/chat'; import { useChatStore } from '@/stores/chat';
import ChatPanel from '@/components/ChatPanel.vue'; import ChatPanel from '@/components/ChatPanel.vue';
import ChatInputBar from '@/components/ChatInputBar.vue'; import ChatInputBar from '@/components/ChatInputBar.vue';
const props = defineProps<{ projectId: number }>(); const props = defineProps<{ projectId: number }>();
const emit = defineEmits<{
'note-changed': [noteId: number];
'task-changed': [];
}>();
const chatStore = useChatStore(); 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 = 'closed' | 'collapsed' | 'expanded'; type WidgetState = 'closed' | 'collapsed' | 'expanded';
const widgetState = ref<WidgetState>('collapsed'); const widgetState = ref<WidgetState>('collapsed');
+36 -199
View File
@@ -1,15 +1,13 @@
<script setup lang="ts"> <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 { useRoute } from "vue-router";
import { apiGet } from "@/api/client"; import { apiGet } from "@/api/client";
import { useChatStore } from "@/stores/chat";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import ChatPanel from "@/components/ChatPanel.vue";
import WorkspaceTaskPanel from "@/components/WorkspaceTaskPanel.vue"; import WorkspaceTaskPanel from "@/components/WorkspaceTaskPanel.vue";
import WorkspaceNoteEditor from "@/components/WorkspaceNoteEditor.vue"; import WorkspaceNoteEditor from "@/components/WorkspaceNoteEditor.vue";
import WorkspaceChatWidget from "@/components/WorkspaceChatWidget.vue";
const route = useRoute(); const route = useRoute();
const chatStore = useChatStore();
const toast = useToastStore(); const toast = useToastStore();
const projectId = computed(() => Number(route.params.projectId)); const projectId = computed(() => Number(route.params.projectId));
@@ -21,142 +19,62 @@ interface Project {
} }
const project = ref<Project | null>(null); const project = ref<Project | null>(null);
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null); const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null); const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null);
const activeNoteId = ref<number | null>(null); const activeNoteId = ref<number | null>(null);
let workspaceConvId: number | null = null;
let isNewConv = false;
function _storageKey(pid: number) { // Panel collapse state — persisted per project (tasks + notes only; chat is now a widget)
return `workspace_conv_${pid}`;
}
// Panel collapse state — persisted per project
function _panelKey(pid: number) { return `workspace_panels_${pid}`; } function _panelKey(pid: number) { return `workspace_panels_${pid}`; }
function _loadPanelState(pid: number) { function _loadPanelState(pid: number) {
try { try {
const raw = localStorage.getItem(_panelKey(pid)); const raw = localStorage.getItem(_panelKey(pid));
if (raw) { if (raw) {
const saved = JSON.parse(raw); const saved = JSON.parse(raw) as Partial<{ tasks: boolean; notes: boolean }>;
// Ensure at least one panel is open after restore const tasks = saved.tasks ?? true;
if (!saved.tasks && !saved.chat && !saved.notes) saved.chat = true; const notes = saved.notes ?? true;
return saved as { tasks: boolean; chat: boolean; notes: boolean }; // Guard: at least one panel must be open
if (!tasks && !notes) return { tasks: true, notes: true };
return { tasks, notes };
} }
} catch { /* ignore */ } } 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(() => { const gridColumns = computed(() => {
return [ return [
panelOpen.value.tasks ? "minmax(0, 0.8fr)" : "0px", panelOpen.value.tasks ? "minmax(0, 1fr)" : "0px",
panelOpen.value.chat ? "minmax(0, 1.1fr)" : "0px", panelOpen.value.notes ? "minmax(0, 2fr)" : "0px",
panelOpen.value.notes ? "minmax(0, 1.1fr)" : "0px",
].join(" "); ].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) { function togglePanel(panel: keyof typeof panelOpen.value) {
const open = 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; if (open[panel] && openCount <= 1) return;
panelOpen.value[panel] = !panelOpen.value[panel]; panelOpen.value[panel] = !panelOpen.value[panel];
localStorage.setItem(_panelKey(projectId.value), JSON.stringify(panelOpen.value)); localStorage.setItem(_panelKey(projectId.value), JSON.stringify(panelOpen.value));
} }
function prefill(text: string) { function onNoteChanged(noteId: number) {
chatPanelRef.value?.prefill(text); activeNoteId.value = noteId;
noteEditorRef.value?.reload();
}
function onTaskChanged() {
taskPanelRef.value?.reload();
} }
onMounted(async () => { onMounted(async () => {
// Restore panel state
panelOpen.value = _loadPanelState(projectId.value); panelOpen.value = _loadPanelState(projectId.value);
// Load project info
try { try {
project.value = await apiGet<Project>(`/api/projects/${projectId.value}`); project.value = await apiGet<Project>(`/api/projects/${projectId.value}`);
} catch { } catch {
toast.show("Failed to load project", "error"); 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> </script>
@@ -176,13 +94,6 @@ onUnmounted(async () => {
> >
Tasks Tasks
</button> </button>
<button
:class="['panel-toggle', { active: panelOpen.chat }]"
title="Toggle Chat panel"
@click="togglePanel('chat')"
>
Chat
</button>
<button <button
:class="['panel-toggle', { active: panelOpen.notes }]" :class="['panel-toggle', { active: panelOpen.notes }]"
title="Toggle Notes panel" title="Toggle Notes panel"
@@ -193,9 +104,8 @@ onUnmounted(async () => {
</div> </div>
</header> </header>
<!-- Three-panel body --> <!-- Two-panel body -->
<div class="ws-body" :style="{ gridTemplateColumns: gridColumns }"> <div class="ws-body" :style="{ gridTemplateColumns: gridColumns }">
<!-- Left: Tasks --> <!-- Left: Tasks -->
<div v-show="panelOpen.tasks" class="ws-panel"> <div v-show="panelOpen.tasks" class="ws-panel">
<Transition name="panel-fade"> <Transition name="panel-fade">
@@ -209,35 +119,8 @@ onUnmounted(async () => {
</Transition> </Transition>
</div> </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 --> <!-- 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"> <Transition name="panel-fade">
<div v-if="panelOpen.notes" class="panel-inner"> <div v-if="panelOpen.notes" class="panel-inner">
<WorkspaceNoteEditor <WorkspaceNoteEditor
@@ -249,13 +132,21 @@ onUnmounted(async () => {
</div> </div>
</Transition> </Transition>
</div> </div>
</div> </div>
<!-- Floating chat widget -->
<WorkspaceChatWidget
v-if="project"
:project-id="projectId"
@note-changed="onNoteChanged"
@task-changed="onTaskChanged"
/>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.workspace-root { .workspace-root {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
@@ -263,7 +154,6 @@ onUnmounted(async () => {
background: var(--color-bg); background: var(--color-bg);
} }
/* Header */
.ws-header { .ws-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -318,7 +208,6 @@ onUnmounted(async () => {
color: var(--color-primary); color: var(--color-primary);
} }
/* Three-panel layout */
.ws-body { .ws-body {
display: grid; display: grid;
flex: 1; flex: 1;
@@ -331,6 +220,10 @@ onUnmounted(async () => {
min-width: 0; min-width: 0;
} }
.ws-panel-notes {
border-left: 1px solid var(--color-border);
}
.panel-inner { .panel-inner {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -345,60 +238,4 @@ onUnmounted(async () => {
.panel-fade-leave-to { .panel-fade-leave-to {
opacity: 0; 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> </style>