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>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user