feat(workspace): history dropdown on project chat widget

Clicking the ▾ button reveals conversations where
rag_project_id === projectId, most-recent first. Selecting one
repoints the widget (and the workspace_conv_{pid} storage key) to
that conversation without deleting the previously active one.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 11:10:54 -04:00
parent 6026c24450
commit 649c0f124b
+118 -10
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted, watch } from 'vue';
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';
@@ -55,9 +55,36 @@ 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;
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}`;
}
@@ -75,7 +102,7 @@ function close() { widgetState.value = 'closed'; }
async function restart() {
const conv = await chatStore.createConversation();
workspaceConvId = conv.id;
workspaceConvId.value = conv.id;
isNewConv = true;
localStorage.setItem(_storageKey(props.projectId), String(conv.id));
await chatStore.fetchConversation(conv.id);
@@ -96,6 +123,10 @@ function prefill(text: string) {
}
onMounted(async () => {
if (chatStore.conversations.length === 0) {
await chatStore.fetchConversations();
}
const key = _storageKey(props.projectId);
const storedId = localStorage.getItem(key);
@@ -103,7 +134,7 @@ onMounted(async () => {
const existingId = Number(storedId);
try {
await chatStore.fetchConversation(existingId);
workspaceConvId = existingId;
workspaceConvId.value = existingId;
isNewConv = false;
chatStore.reconnectIfGenerating(existingId);
} catch {
@@ -111,9 +142,9 @@ onMounted(async () => {
}
}
if (workspaceConvId === null) {
if (workspaceConvId.value === null) {
const conv = await chatStore.createConversation();
workspaceConvId = conv.id;
workspaceConvId.value = conv.id;
isNewConv = true;
await chatStore.fetchConversation(conv.id);
localStorage.setItem(key, String(conv.id));
@@ -121,10 +152,11 @@ onMounted(async () => {
});
onUnmounted(async () => {
if (workspaceConvId !== null && isNewConv) {
const conv = chatStore.conversations.find((c) => c.id === workspaceConvId);
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(workspaceConvId);
await chatStore.deleteConversation(id);
localStorage.removeItem(_storageKey(props.projectId));
}
}
@@ -154,7 +186,28 @@ defineExpose({ prefill });
<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>
<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">
@@ -272,6 +325,61 @@ defineExpose({ prefill });
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;