Files
FabledScribe/frontend/src/components/WorkspaceChatWidget.vue
T
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

275 lines
7.1 KiB
Vue

<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } 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 chatStore = useChatStore();
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>