From 0b1ed2afe56cd2989962998ca1a7206c285c886e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 3 Apr 2026 17:47:17 -0400 Subject: [PATCH] refactor: migrate WorkspaceView chat to use ChatPanel component Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/WorkspaceView.vue | 489 +++------------------------ 1 file changed, 42 insertions(+), 447 deletions(-) diff --git a/frontend/src/views/WorkspaceView.vue b/frontend/src/views/WorkspaceView.vue index 4e3e9ff..9df875b 100644 --- a/frontend/src/views/WorkspaceView.vue +++ b/frontend/src/views/WorkspaceView.vue @@ -3,28 +3,14 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue"; import { useRoute } from "vue-router"; import { apiGet } from "@/api/client"; import { useChatStore } from "@/stores/chat"; -import { useSettingsStore } from "@/stores/settings"; import { useToastStore } from "@/stores/toast"; -import { useListenMode } from "@/composables/useListenMode"; -import { useStreamingTts } from "@/composables/useStreamingTts"; -import { renderMarkdown } from "@/utils/markdown"; -import ChatMessage from "@/components/ChatMessage.vue"; -import ToolCallCard from "@/components/ToolCallCard.vue"; -import ToolConfirmCard from "@/components/ToolConfirmCard.vue"; +import ChatPanel from "@/components/ChatPanel.vue"; import WorkspaceTaskPanel from "@/components/WorkspaceTaskPanel.vue"; import WorkspaceNoteEditor from "@/components/WorkspaceNoteEditor.vue"; const route = useRoute(); const chatStore = useChatStore(); -const settingsStore = useSettingsStore(); const toast = useToastStore(); -const listenMode = useListenMode(); -const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady); -const tts = useStreamingTts({ - streamingContent: computed(() => chatStore.streamingContent), - streaming: computed(() => chatStore.streaming), - enabled: computed(() => listenMode.value && voiceTtsEnabled.value), -}); const projectId = computed(() => Number(route.params.projectId)); @@ -35,9 +21,7 @@ interface Project { } const project = ref(null); -const messageInput = ref(""); -const messagesEl = ref(null); -const inputEl = ref(null); +const chatPanelRef = ref | null>(null); const taskPanelRef = ref | null>(null); const noteEditorRef = ref | null>(null); const activeNoteId = ref(null); @@ -74,7 +58,7 @@ const gridColumns = computed(() => { ].join(" "); }); -// SSE watcher +// SSE watcher — auto-load notes/tasks when tool calls succeed const processedCount = ref(0); watch( @@ -105,29 +89,10 @@ watch( watch( () => chatStore.streaming, (s) => { - if (!s) { - processedCount.value = 0; - scrollToBottom(); - } + if (!s) processedCount.value = 0; } ); -const streamingRendered = computed(() => { - if (!chatStore.streamingContent) return ""; - return renderMarkdown(chatStore.streamingContent); -}); - -function scrollToBottom() { - nextTick(() => { - if (messagesEl.value) { - messagesEl.value.scrollTop = messagesEl.value.scrollHeight; - } - }); -} - -watch(() => chatStore.streamingContent, scrollToBottom); -watch(() => chatStore.currentConversation?.messages.length, scrollToBottom); - function togglePanel(panel: keyof typeof panelOpen.value) { const open = panelOpen.value; const openCount = [open.tasks, open.chat, open.notes].filter(Boolean).length; @@ -137,54 +102,7 @@ function togglePanel(panel: keyof typeof panelOpen.value) { } function prefill(text: string) { - messageInput.value = text; - nextTick(() => inputEl.value?.focus()); -} - -async function sendMessage() { - const content = messageInput.value.trim(); - if (!content) return; - - messageInput.value = ""; - resetTextareaHeight(); - - await chatStore.sendMessage( - content, - undefined, - undefined, - true, - undefined, - undefined, - projectId.value, - projectId.value, - ); - scrollToBottom(); - nextTick(() => inputEl.value?.focus()); -} - -function onInputKeydown(e: KeyboardEvent) { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - sendMessage(); - } - if (e.key === "Escape") { - // Prevent App.vue from navigating home when textarea is focused - e.stopPropagation(); - inputEl.value?.blur(); - } -} - -function autoResize() { - const el = inputEl.value; - if (!el) return; - el.style.height = "auto"; - el.style.height = Math.min(el.scrollHeight, 150) + "px"; -} - -function resetTextareaHeight() { - const el = inputEl.value; - if (!el) return; - el.style.height = "auto"; + chatPanelRef.value?.prefill(text); } onMounted(async () => { @@ -225,7 +143,7 @@ onMounted(async () => { localStorage.setItem(key, String(conv.id)); } - nextTick(() => inputEl.value?.focus()); + nextTick(() => chatPanelRef.value?.focus()); }); onUnmounted(async () => { @@ -290,130 +208,30 @@ onUnmounted(async () => { -
-
- - - -
-
-
- {{ settingsStore.assistantName }} -
-
- -
- -
- - {{ chatStore.streamingStatus }} -
-
- Reasoning -
{{ chatStore.streamingThinking }}
-
-
- -
-
- - - - -
-

What would you like to work on?

-
- - - -
+
-
- -
- - - - - -
+
@@ -527,25 +345,31 @@ onUnmounted(async () => { } /* Chat panel */ -.ws-chat-panel { - display: flex; - flex-direction: column; +.ws-panel-chat { border-left: 1px solid var(--color-border); border-right: 1px solid var(--color-border); -} - -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1rem; display: flex; flex-direction: column; - gap: 0.75rem; +} + +.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; @@ -557,6 +381,7 @@ onUnmounted(async () => { flex-wrap: wrap; gap: 0.5rem; justify-content: center; + pointer-events: auto; } .quick-chip { background: var(--color-surface); @@ -574,234 +399,4 @@ onUnmounted(async () => { color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); } - -.chat-input-area { - display: flex; - gap: 0.5rem; - padding: 0.6rem; - border-top: 1px solid var(--color-border); - flex-shrink: 0; -} - -.chat-input { - flex: 1; - resize: none; - background: var(--color-input-bg, var(--color-bg)); - border: 1px solid var(--color-border); - border-radius: 6px; - padding: 0.4rem 0.6rem; - font-size: 0.875rem; - font-family: inherit; - color: var(--color-text); - line-height: 1.5; - overflow-y: hidden; -} -.chat-input:focus { - outline: none; - border-color: var(--color-primary); -} - -.btn-send { - background: var(--color-primary); - color: #fff; - border: none; - border-radius: 6px; - padding: 0.4rem 0.9rem; - font-size: 0.875rem; - cursor: pointer; - align-self: flex-end; -} -.btn-send:disabled { - opacity: 0.4; - cursor: default; -} - -.btn-abort { - background: none; - color: var(--color-danger, #e74c3c); - border: 1px solid var(--color-danger, #e74c3c); - border-radius: 6px; - padding: 0.4rem 0.9rem; - font-size: 0.875rem; - font-weight: 600; - cursor: pointer; - align-self: flex-end; - white-space: nowrap; -} -.btn-abort:hover { - background: var(--color-danger, #e74c3c); - color: #fff; -} - -.btn-listen-ws { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background: transparent; - color: var(--color-text-muted); - cursor: pointer; - transition: color 0.15s, background 0.15s, border-color 0.15s; -} -.btn-listen-ws:hover { - color: var(--color-text); - border-color: var(--color-primary); -} -.btn-listen-ws--active { - color: var(--color-primary); - border-color: var(--color-primary); - background: color-mix(in srgb, var(--color-primary) 10%, transparent); -} -.btn-listen-ws--busy { - color: var(--color-primary); - animation: pulse 1.2s ease-in-out infinite; -} - -/* Streaming indicators (mirror ChatView) */ -.chat-message { - display: flex; -} -.role-assistant { - justify-content: flex-start; -} -.message-bubble { - max-width: 85%; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: 10px; - padding: 0.65rem 0.85rem; -} -.message-header { - margin-bottom: 0.3rem; -} -.role-label { - font-size: 0.72rem; - font-weight: 600; - color: var(--color-primary); - text-transform: uppercase; - letter-spacing: 0.04em; -} -.streaming-tool-calls { - margin-bottom: 0.4rem; -} -.streaming-status-line { - display: flex; - align-items: center; - gap: 0.4rem; - font-size: 0.8rem; - color: var(--color-text-muted); - margin-bottom: 0.3rem; -} -.streaming-status-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--color-primary); - animation: pulse 1s infinite; -} -.typing-indicator { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--color-primary); - animation: pulse 1s infinite; -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } -} - -.message-content :deep(p) { margin: 0 0 0.5em; } -.message-content :deep(p:last-child) { margin-bottom: 0; } - -.thinking-block { - margin-bottom: 0.5rem; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - overflow: hidden; -} -.thinking-summary { - padding: 0.25rem 0.5rem; - font-size: 0.72rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--color-text-muted); - cursor: pointer; - user-select: none; - list-style: none; - display: flex; - align-items: center; - gap: 0.3rem; - background: var(--color-bg-secondary); -} -.thinking-summary::-webkit-details-marker { display: none; } -.thinking-summary::before { - content: "▶"; - font-size: 0.6rem; - transition: transform 0.15s; -} -details[open] .thinking-summary::before { - transform: rotate(90deg); -} -.thinking-text { - margin: 0; - padding: 0.5rem; - font-size: 0.8rem; - line-height: 1.5; - color: var(--color-text-secondary); - white-space: pre-wrap; - word-break: break-word; - max-height: 300px; - overflow-y: auto; -} -.ws-message.role-user { - display: flex; - justify-content: flex-end; -} -.queued-bubble { - max-width: 80%; - padding: 0.75rem 1rem; - border-radius: 18px; - border-bottom-right-radius: 4px; - background: linear-gradient(135deg, #6366f1, #4f46e5); - color: #fff; - opacity: 0.45; - font-size: 0.95rem; - line-height: 1.55; - word-break: break-word; -} -.queued-badge { - font-size: 0.65rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: rgba(255, 255, 255, 0.75); - margin-bottom: 0.2rem; -} -.queued-clear-row { - display: flex; - justify-content: flex-end; - padding-right: 0.5rem; -} -.queued-clear-btn { - background: none; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm, 6px); - cursor: pointer; - color: var(--color-text-muted); - font-size: 0.78rem; - padding: 0.2rem 0.6rem; - font-family: inherit; -} -.queued-clear-btn:hover { - color: var(--color-danger, #e74c3c); - border-color: var(--color-danger, #e74c3c); -}