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>
316 lines
8.3 KiB
Vue
316 lines
8.3 KiB
Vue
<script setup lang="ts">
|
|
import { ref, 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 = 'closed' | 'collapsed' | 'expanded';
|
|
const widgetState = ref<WidgetState>('collapsed');
|
|
|
|
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
|
|
const inputBarRef = ref<InstanceType<typeof ChatInputBar> | null>(null);
|
|
|
|
let workspaceConvId: number | null = null;
|
|
let isNewConv = false;
|
|
|
|
function _storageKey(pid: number) {
|
|
return `workspace_conv_${pid}`;
|
|
}
|
|
|
|
function open() { widgetState.value = 'collapsed'; }
|
|
function expand() {
|
|
widgetState.value = 'expanded';
|
|
nextTick(() => chatPanelRef.value?.focus());
|
|
}
|
|
function collapse() {
|
|
widgetState.value = 'collapsed';
|
|
nextTick(() => inputBarRef.value?.focus());
|
|
}
|
|
function close() { widgetState.value = 'closed'; }
|
|
|
|
async function restart() {
|
|
const conv = await chatStore.createConversation();
|
|
workspaceConvId = 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 () => {
|
|
const key = _storageKey(props.projectId);
|
|
const storedId = localStorage.getItem(key);
|
|
|
|
if (storedId) {
|
|
const existingId = Number(storedId);
|
|
try {
|
|
await chatStore.fetchConversation(existingId);
|
|
workspaceConvId = existingId;
|
|
isNewConv = false;
|
|
chatStore.reconnectIfGenerating(existingId);
|
|
} catch {
|
|
localStorage.removeItem(key);
|
|
}
|
|
}
|
|
|
|
if (workspaceConvId === null) {
|
|
const conv = await chatStore.createConversation();
|
|
workspaceConvId = conv.id;
|
|
isNewConv = true;
|
|
await chatStore.fetchConversation(conv.id);
|
|
localStorage.setItem(key, String(conv.id));
|
|
}
|
|
});
|
|
|
|
onUnmounted(async () => {
|
|
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(props.projectId));
|
|
}
|
|
}
|
|
});
|
|
|
|
defineExpose({ prefill });
|
|
</script>
|
|
|
|
<template>
|
|
<!-- Closed: small floating handle -->
|
|
<button
|
|
v-if="widgetState === 'closed'"
|
|
class="ws-chat-handle"
|
|
title="Open project chat"
|
|
@click="open"
|
|
>
|
|
💬 Chat
|
|
</button>
|
|
|
|
<!-- Collapsed or expanded: floating panel -->
|
|
<div
|
|
v-else
|
|
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">
|
|
<button class="btn-icon-sm" title="History" disabled>▾</button>
|
|
<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>
|
|
<button class="btn-icon-sm" title="Close chat" @click="close">✕</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-handle {
|
|
position: absolute;
|
|
right: 1rem;
|
|
bottom: 1rem;
|
|
z-index: 20;
|
|
padding: 0.5rem 0.9rem;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--color-border);
|
|
background: var(--color-surface);
|
|
color: var(--color-text);
|
|
cursor: pointer;
|
|
font-size: 0.82rem;
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
|
}
|
|
.ws-chat-handle:hover {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.ws-chat-widget {
|
|
position: absolute;
|
|
right: 1rem;
|
|
bottom: 1rem;
|
|
z-index: 20;
|
|
width: 380px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 12px;
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
|
overflow: hidden;
|
|
transition: height 0.2s ease, width 0.2s ease;
|
|
}
|
|
.ws-chat-widget--expanded {
|
|
width: 420px;
|
|
height: 560px;
|
|
}
|
|
|
|
.ws-chat-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--color-border);
|
|
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;
|
|
}
|
|
|
|
.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>
|