Merge pull request 'feat(workspace): redesign project workspace with floating chat widget' (#42) from dev into main
This commit was merged in pull request #42.
This commit is contained in:
@@ -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 ── */
|
||||
.note-rail {
|
||||
width: 155px;
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card, var(--color-bg-secondary));
|
||||
}
|
||||
|
||||
.rail-header {
|
||||
@@ -551,13 +551,13 @@ defineExpose({ reload: loadProjectNotes });
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||
cursor: pointer;
|
||||
gap: 0.15rem;
|
||||
border-left: 2px solid transparent;
|
||||
border-right: 2px solid transparent;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
||||
.note-row.active {
|
||||
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 {
|
||||
@@ -715,7 +715,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -742,8 +741,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.btn-save:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.note-title-row {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 0.9rem 1.1rem 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -751,19 +749,25 @@ defineExpose({ reload: loadProjectNotes });
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--color-text);
|
||||
padding: 0;
|
||||
font-family: 'Fraunces', serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.note-title-input:focus { outline: none; }
|
||||
.note-title-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||
@@ -789,7 +793,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -824,7 +827,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
|
||||
.toolbar-row {
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -834,7 +836,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<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 { apiGet } from "@/api/client";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import ChatPanel from "@/components/ChatPanel.vue";
|
||||
import WorkspaceTaskPanel from "@/components/WorkspaceTaskPanel.vue";
|
||||
import WorkspaceNoteEditor from "@/components/WorkspaceNoteEditor.vue";
|
||||
import WorkspaceChatWidget from "@/components/WorkspaceChatWidget.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const chatStore = useChatStore();
|
||||
const toast = useToastStore();
|
||||
|
||||
const projectId = computed(() => Number(route.params.projectId));
|
||||
@@ -21,142 +19,62 @@ interface Project {
|
||||
}
|
||||
|
||||
const project = ref<Project | null>(null);
|
||||
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
|
||||
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
|
||||
const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null);
|
||||
const activeNoteId = ref<number | null>(null);
|
||||
let workspaceConvId: number | null = null;
|
||||
let isNewConv = false;
|
||||
|
||||
function _storageKey(pid: number) {
|
||||
return `workspace_conv_${pid}`;
|
||||
}
|
||||
|
||||
// Panel collapse state — persisted per project
|
||||
// Panel collapse state — persisted per project (tasks + notes only; chat is now a widget)
|
||||
function _panelKey(pid: number) { return `workspace_panels_${pid}`; }
|
||||
|
||||
function _loadPanelState(pid: number) {
|
||||
try {
|
||||
const raw = localStorage.getItem(_panelKey(pid));
|
||||
if (raw) {
|
||||
const saved = JSON.parse(raw);
|
||||
// Ensure at least one panel is open after restore
|
||||
if (!saved.tasks && !saved.chat && !saved.notes) saved.chat = true;
|
||||
return saved as { tasks: boolean; chat: boolean; notes: boolean };
|
||||
const saved = JSON.parse(raw) as Partial<{ tasks: boolean; notes: boolean }>;
|
||||
const tasks = saved.tasks ?? true;
|
||||
const notes = saved.notes ?? true;
|
||||
// Guard: at least one panel must be open
|
||||
if (!tasks && !notes) return { tasks: true, notes: true };
|
||||
return { tasks, notes };
|
||||
}
|
||||
} 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(() => {
|
||||
return [
|
||||
panelOpen.value.tasks ? "minmax(0, 0.8fr)" : "0px",
|
||||
panelOpen.value.chat ? "minmax(0, 1.1fr)" : "0px",
|
||||
panelOpen.value.notes ? "minmax(0, 1.1fr)" : "0px",
|
||||
panelOpen.value.tasks ? "minmax(0, 0.85fr)" : "0px",
|
||||
panelOpen.value.notes ? "minmax(0, 2.15fr)" : "0px",
|
||||
].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) {
|
||||
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;
|
||||
panelOpen.value[panel] = !panelOpen.value[panel];
|
||||
localStorage.setItem(_panelKey(projectId.value), JSON.stringify(panelOpen.value));
|
||||
}
|
||||
|
||||
function prefill(text: string) {
|
||||
chatPanelRef.value?.prefill(text);
|
||||
function onNoteChanged(noteId: number) {
|
||||
activeNoteId.value = noteId;
|
||||
noteEditorRef.value?.reload();
|
||||
}
|
||||
|
||||
function onTaskChanged() {
|
||||
taskPanelRef.value?.reload();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Restore panel state
|
||||
panelOpen.value = _loadPanelState(projectId.value);
|
||||
|
||||
// Load project info
|
||||
try {
|
||||
project.value = await apiGet<Project>(`/api/projects/${projectId.value}`);
|
||||
} catch {
|
||||
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>
|
||||
|
||||
@@ -176,13 +94,6 @@ onUnmounted(async () => {
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
<button
|
||||
:class="['panel-toggle', { active: panelOpen.chat }]"
|
||||
title="Toggle Chat panel"
|
||||
@click="togglePanel('chat')"
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
:class="['panel-toggle', { active: panelOpen.notes }]"
|
||||
title="Toggle Notes panel"
|
||||
@@ -193,9 +104,8 @@ onUnmounted(async () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Three-panel body -->
|
||||
<!-- Two-panel body -->
|
||||
<div class="ws-body" :style="{ gridTemplateColumns: gridColumns }">
|
||||
|
||||
<!-- Left: Tasks -->
|
||||
<div v-show="panelOpen.tasks" class="ws-panel">
|
||||
<Transition name="panel-fade">
|
||||
@@ -209,35 +119,8 @@ onUnmounted(async () => {
|
||||
</Transition>
|
||||
</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 -->
|
||||
<div v-show="panelOpen.notes" class="ws-panel">
|
||||
<div v-show="panelOpen.notes" class="ws-panel ws-panel-notes">
|
||||
<Transition name="panel-fade">
|
||||
<div v-if="panelOpen.notes" class="panel-inner">
|
||||
<WorkspaceNoteEditor
|
||||
@@ -249,13 +132,21 @@ onUnmounted(async () => {
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Floating chat widget -->
|
||||
<WorkspaceChatWidget
|
||||
v-if="project"
|
||||
:project-id="projectId"
|
||||
@note-changed="onNoteChanged"
|
||||
@task-changed="onTaskChanged"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@@ -263,7 +154,6 @@ onUnmounted(async () => {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.ws-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -318,7 +208,6 @@ onUnmounted(async () => {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Three-panel layout */
|
||||
.ws-body {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
@@ -331,6 +220,10 @@ onUnmounted(async () => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ws-panel-notes {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-inner {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -345,60 +238,4 @@ onUnmounted(async () => {
|
||||
.panel-fade-leave-to {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user