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:
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user