f75c40b417
- 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>
391 lines
10 KiB
Vue
391 lines
10 KiB
Vue
<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>
|