Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96a44886ef | |||
| f422d4158c | |||
| 70ec060b7a | |||
| 3b41a45757 | |||
| 19e1ca505d | |||
| f75c40b417 | |||
| 649c0f124b | |||
| 6026c24450 | |||
| ae5c61a179 |
@@ -0,0 +1,390 @@
|
|||||||
|
<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>
|
||||||
@@ -457,12 +457,12 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
|
|
||||||
/* ── Left rail ── */
|
/* ── Left rail ── */
|
||||||
.note-rail {
|
.note-rail {
|
||||||
width: 155px;
|
width: 200px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-right: 1px solid var(--color-border);
|
background: var(--color-bg-card, var(--color-bg-secondary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-header {
|
.rail-header {
|
||||||
@@ -551,13 +551,13 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
border-left: 2px solid transparent;
|
border-right: 2px solid transparent;
|
||||||
transition: background 0.12s;
|
transition: background 0.12s;
|
||||||
}
|
}
|
||||||
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
||||||
.note-row.active {
|
.note-row.active {
|
||||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||||
border-left-color: var(--color-primary);
|
border-right-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-row-main {
|
.note-row-main {
|
||||||
@@ -715,7 +715,6 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -742,8 +741,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
.btn-save:disabled { opacity: 0.4; cursor: default; }
|
.btn-save:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
.note-title-row {
|
.note-title-row {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.9rem 1.1rem 0.5rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,19 +749,25 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 1rem;
|
font-size: 1.4rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
.note-title-input:focus { outline: none; }
|
.note-title-input:focus { outline: none; }
|
||||||
|
.note-title-input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-row {
|
.tag-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||||
@@ -789,7 +793,6 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
padding: 0.35rem 0.6rem;
|
padding: 0.35rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -824,7 +827,6 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
|
|
||||||
.toolbar-row {
|
.toolbar-row {
|
||||||
padding: 0.3rem 0.6rem;
|
padding: 0.3rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,7 +836,6 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.3rem 0.6rem;
|
padding: 0.3rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { apiGet } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
import { useChatStore } from "@/stores/chat";
|
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import ChatPanel from "@/components/ChatPanel.vue";
|
|
||||||
import WorkspaceTaskPanel from "@/components/WorkspaceTaskPanel.vue";
|
import WorkspaceTaskPanel from "@/components/WorkspaceTaskPanel.vue";
|
||||||
import WorkspaceNoteEditor from "@/components/WorkspaceNoteEditor.vue";
|
import WorkspaceNoteEditor from "@/components/WorkspaceNoteEditor.vue";
|
||||||
|
import WorkspaceChatWidget from "@/components/WorkspaceChatWidget.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const chatStore = useChatStore();
|
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
|
|
||||||
const projectId = computed(() => Number(route.params.projectId));
|
const projectId = computed(() => Number(route.params.projectId));
|
||||||
@@ -21,142 +19,62 @@ interface Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = ref<Project | null>(null);
|
const project = ref<Project | null>(null);
|
||||||
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
|
|
||||||
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
|
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
|
||||||
const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null);
|
const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null);
|
||||||
const activeNoteId = ref<number | null>(null);
|
const activeNoteId = ref<number | null>(null);
|
||||||
let workspaceConvId: number | null = null;
|
|
||||||
let isNewConv = false;
|
|
||||||
|
|
||||||
function _storageKey(pid: number) {
|
// Panel collapse state — persisted per project (tasks + notes only; chat is now a widget)
|
||||||
return `workspace_conv_${pid}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Panel collapse state — persisted per project
|
|
||||||
function _panelKey(pid: number) { return `workspace_panels_${pid}`; }
|
function _panelKey(pid: number) { return `workspace_panels_${pid}`; }
|
||||||
|
|
||||||
function _loadPanelState(pid: number) {
|
function _loadPanelState(pid: number) {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(_panelKey(pid));
|
const raw = localStorage.getItem(_panelKey(pid));
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const saved = JSON.parse(raw);
|
const saved = JSON.parse(raw) as Partial<{ tasks: boolean; notes: boolean }>;
|
||||||
// Ensure at least one panel is open after restore
|
const tasks = saved.tasks ?? true;
|
||||||
if (!saved.tasks && !saved.chat && !saved.notes) saved.chat = true;
|
const notes = saved.notes ?? true;
|
||||||
return saved as { tasks: boolean; chat: boolean; notes: boolean };
|
// Guard: at least one panel must be open
|
||||||
|
if (!tasks && !notes) return { tasks: true, notes: true };
|
||||||
|
return { tasks, notes };
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return { tasks: true, chat: true, notes: true };
|
return { tasks: true, notes: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const panelOpen = ref({ tasks: true, chat: true, notes: true });
|
const panelOpen = ref<{ tasks: boolean; notes: boolean }>({ tasks: true, notes: true });
|
||||||
|
|
||||||
const gridColumns = computed(() => {
|
const gridColumns = computed(() => {
|
||||||
return [
|
return [
|
||||||
panelOpen.value.tasks ? "minmax(0, 0.8fr)" : "0px",
|
panelOpen.value.tasks ? "minmax(0, 0.85fr)" : "0px",
|
||||||
panelOpen.value.chat ? "minmax(0, 1.1fr)" : "0px",
|
panelOpen.value.notes ? "minmax(0, 2.15fr)" : "0px",
|
||||||
panelOpen.value.notes ? "minmax(0, 1.1fr)" : "0px",
|
|
||||||
].join(" ");
|
].join(" ");
|
||||||
});
|
});
|
||||||
|
|
||||||
// SSE watcher — auto-load notes/tasks when tool calls succeed
|
|
||||||
const processedCount = ref(0);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => chatStore.streamingToolCalls,
|
|
||||||
(calls) => {
|
|
||||||
for (let i = processedCount.value; i < calls.length; i++) {
|
|
||||||
const tc = calls[i];
|
|
||||||
if (
|
|
||||||
["create_note", "update_note"].includes(tc.function) &&
|
|
||||||
tc.status === "success" &&
|
|
||||||
tc.result?.data?.id
|
|
||||||
) {
|
|
||||||
activeNoteId.value = tc.result.data.id as number;
|
|
||||||
noteEditorRef.value?.reload();
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
tc.status === "success" && (
|
|
||||||
["create_milestone", "update_milestone"].includes(tc.function) ||
|
|
||||||
(tc.function === "create_note" && tc.result?.data?.status) ||
|
|
||||||
(tc.function === "update_note" && tc.result?.data?.status !== undefined)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
taskPanelRef.value?.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
processedCount.value = calls.length;
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => chatStore.streaming,
|
|
||||||
(s) => {
|
|
||||||
if (!s) processedCount.value = 0;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function togglePanel(panel: keyof typeof panelOpen.value) {
|
function togglePanel(panel: keyof typeof panelOpen.value) {
|
||||||
const open = panelOpen.value;
|
const open = panelOpen.value;
|
||||||
const openCount = [open.tasks, open.chat, open.notes].filter(Boolean).length;
|
const openCount = [open.tasks, open.notes].filter(Boolean).length;
|
||||||
if (open[panel] && openCount <= 1) return;
|
if (open[panel] && openCount <= 1) return;
|
||||||
panelOpen.value[panel] = !panelOpen.value[panel];
|
panelOpen.value[panel] = !panelOpen.value[panel];
|
||||||
localStorage.setItem(_panelKey(projectId.value), JSON.stringify(panelOpen.value));
|
localStorage.setItem(_panelKey(projectId.value), JSON.stringify(panelOpen.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function prefill(text: string) {
|
function onNoteChanged(noteId: number) {
|
||||||
chatPanelRef.value?.prefill(text);
|
activeNoteId.value = noteId;
|
||||||
|
noteEditorRef.value?.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTaskChanged() {
|
||||||
|
taskPanelRef.value?.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Restore panel state
|
|
||||||
panelOpen.value = _loadPanelState(projectId.value);
|
panelOpen.value = _loadPanelState(projectId.value);
|
||||||
|
|
||||||
// Load project info
|
|
||||||
try {
|
try {
|
||||||
project.value = await apiGet<Project>(`/api/projects/${projectId.value}`);
|
project.value = await apiGet<Project>(`/api/projects/${projectId.value}`);
|
||||||
} catch {
|
} catch {
|
||||||
toast.show("Failed to load project", "error");
|
toast.show("Failed to load project", "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = _storageKey(projectId.value);
|
|
||||||
const storedId = localStorage.getItem(key);
|
|
||||||
|
|
||||||
if (storedId) {
|
|
||||||
// Try to reuse the existing workspace conversation
|
|
||||||
const existingId = Number(storedId);
|
|
||||||
try {
|
|
||||||
await chatStore.fetchConversation(existingId);
|
|
||||||
workspaceConvId = existingId;
|
|
||||||
isNewConv = false;
|
|
||||||
chatStore.reconnectIfGenerating(existingId);
|
|
||||||
} catch {
|
|
||||||
// Conversation was deleted — create a fresh one
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workspaceConvId === null) {
|
|
||||||
// Create a new workspace conversation and persist its ID
|
|
||||||
const conv = await chatStore.createConversation();
|
|
||||||
workspaceConvId = conv.id;
|
|
||||||
isNewConv = true;
|
|
||||||
await chatStore.fetchConversation(conv.id);
|
|
||||||
localStorage.setItem(key, String(conv.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
nextTick(() => chatPanelRef.value?.focus());
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(async () => {
|
|
||||||
// Only delete if we created a brand-new conversation this session and it's still empty
|
|
||||||
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(projectId.value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -176,13 +94,6 @@ onUnmounted(async () => {
|
|||||||
>
|
>
|
||||||
Tasks
|
Tasks
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
:class="['panel-toggle', { active: panelOpen.chat }]"
|
|
||||||
title="Toggle Chat panel"
|
|
||||||
@click="togglePanel('chat')"
|
|
||||||
>
|
|
||||||
Chat
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
:class="['panel-toggle', { active: panelOpen.notes }]"
|
:class="['panel-toggle', { active: panelOpen.notes }]"
|
||||||
title="Toggle Notes panel"
|
title="Toggle Notes panel"
|
||||||
@@ -193,9 +104,8 @@ onUnmounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Three-panel body -->
|
<!-- Two-panel body -->
|
||||||
<div class="ws-body" :style="{ gridTemplateColumns: gridColumns }">
|
<div class="ws-body" :style="{ gridTemplateColumns: gridColumns }">
|
||||||
|
|
||||||
<!-- Left: Tasks -->
|
<!-- Left: Tasks -->
|
||||||
<div v-show="panelOpen.tasks" class="ws-panel">
|
<div v-show="panelOpen.tasks" class="ws-panel">
|
||||||
<Transition name="panel-fade">
|
<Transition name="panel-fade">
|
||||||
@@ -209,35 +119,8 @@ onUnmounted(async () => {
|
|||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: Chat -->
|
|
||||||
<div v-show="panelOpen.chat" class="ws-panel ws-panel-chat">
|
|
||||||
<Transition name="panel-fade">
|
|
||||||
<div v-if="panelOpen.chat" class="panel-inner panel-inner-chat">
|
|
||||||
<!-- Quick chips (shown when conversation is empty) -->
|
|
||||||
<div
|
|
||||||
v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
|
|
||||||
class="empty-chat-prompt"
|
|
||||||
>
|
|
||||||
<p class="empty-hint">What would you like to work on?</p>
|
|
||||||
<div class="quick-chips">
|
|
||||||
<button class="quick-chip" @click="prefill('Summarize the current status of this project')">📊 Project status</button>
|
|
||||||
<button class="quick-chip" @click="prefill('Create a note about ')">📝 New note</button>
|
|
||||||
<button class="quick-chip" @click="prefill('Add tasks for ')">✓ Add tasks</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChatPanel
|
|
||||||
ref="chatPanelRef"
|
|
||||||
variant="full"
|
|
||||||
:projectId="projectId"
|
|
||||||
placeholder="Message the agent… (Enter to send)"
|
|
||||||
class="ws-chat-panel"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: Note Editor -->
|
<!-- Right: Note Editor -->
|
||||||
<div v-show="panelOpen.notes" class="ws-panel">
|
<div v-show="panelOpen.notes" class="ws-panel ws-panel-notes">
|
||||||
<Transition name="panel-fade">
|
<Transition name="panel-fade">
|
||||||
<div v-if="panelOpen.notes" class="panel-inner">
|
<div v-if="panelOpen.notes" class="panel-inner">
|
||||||
<WorkspaceNoteEditor
|
<WorkspaceNoteEditor
|
||||||
@@ -249,13 +132,21 @@ onUnmounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating chat widget -->
|
||||||
|
<WorkspaceChatWidget
|
||||||
|
v-if="project"
|
||||||
|
:project-id="projectId"
|
||||||
|
@note-changed="onNoteChanged"
|
||||||
|
@task-changed="onTaskChanged"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.workspace-root {
|
.workspace-root {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -263,7 +154,6 @@ onUnmounted(async () => {
|
|||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.ws-header {
|
.ws-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -318,7 +208,6 @@ onUnmounted(async () => {
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Three-panel layout */
|
|
||||||
.ws-body {
|
.ws-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -331,6 +220,10 @@ onUnmounted(async () => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ws-panel-notes {
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
.panel-inner {
|
.panel-inner {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -345,60 +238,4 @@ onUnmounted(async () => {
|
|||||||
.panel-fade-leave-to {
|
.panel-fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chat panel */
|
|
||||||
.ws-panel-chat {
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
border-right: 1px solid var(--color-border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-inner-chat {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws-chat-panel {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-chat-prompt {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
.empty-hint {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.quick-chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
justify-content: center;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
.quick-chip {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 0.4rem 0.85rem;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.quick-chip:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: var(--color-primary);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user