wraith/frontend/src/components/copilot/CopilotPanel.vue
Vantz Stockwell be868e8172 feat: AI copilot panel — chat UI with streaming, tool visualization, OAuth settings
Add the XO copilot right-side panel with:
- Pinia store (copilot.store.ts) managing panel state, messages, streaming, and token tracking
- useCopilot composable with mock streaming (30-100ms/word) and context-aware responses
- CopilotPanel: 320px collapsible panel with header, scrollable message list, and input area
- CopilotMessage: markdown rendering (bold, code, lists) with streaming cursor animation
- CopilotToolViz: color-coded tool call cards (green=terminal, yellow=SFTP, blue=RDP)
  with pending spinner, done checkmark, expandable input/result JSON
- CopilotSettings: model selector, token usage, clear history, connect/disconnect
- MainLayout integration: ghost icon toggle in toolbar, Ctrl+Shift+K shortcut,
  CSS slide transition, content area resizes (not overlay)

All Wails AIService bindings are TODOs with mock behavior. Pure Tailwind CSS,
no external markdown library.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 09:03:33 -04:00

238 lines
9.8 KiB
Vue

<template>
<div
class="flex flex-col h-full bg-[#0d1117] border-l border-[#30363d] overflow-hidden"
:style="{ width: panelWidth + 'px' }"
>
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2 bg-[#161b22] border-b border-[#30363d] shrink-0">
<div class="flex items-center gap-2">
<!-- Ghost icon -->
<span class="text-base" title="XO AI Copilot">{{ ghostIcon }}</span>
<span class="text-sm font-bold text-[#58a6ff]">XO</span>
<span class="text-[10px] text-[#484f58]">{{ modelShort }}</span>
</div>
<div class="flex items-center gap-2">
<!-- Token counter -->
<span class="text-[10px] text-[#484f58]">
{{ formatTokens(store.tokenUsage.input) }} / {{ formatTokens(store.tokenUsage.output) }}
</span>
<!-- Settings button -->
<button
class="text-[#8b949e] hover:text-[#e0e0e0] transition-colors cursor-pointer"
title="Settings"
@click="store.showSettings = true"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M7.429 1.525a6.593 6.593 0 011.142 0c.036.003.108.036.137.146l.289 1.105c.147.56.55.967.997 1.189.174.086.341.183.501.29.417.278.97.423 1.53.27l1.102-.303c.11-.03.175.016.195.046.219.31.41.641.573.989.014.031.022.11-.059.19l-.815.806c-.411.406-.562.957-.53 1.456a4.588 4.588 0 010 .582c-.032.499.119 1.05.53 1.456l.815.806c.08.08.073.159.059.19a6.494 6.494 0 01-.573.99c-.02.029-.086.074-.195.045l-1.103-.303c-.559-.153-1.112-.008-1.529.27-.16.107-.327.204-.5.29-.449.222-.851.628-.998 1.189l-.289 1.105c-.029.11-.101.143-.137.146a6.613 6.613 0 01-1.142 0c-.036-.003-.108-.037-.137-.146l-.289-1.105c-.147-.56-.55-.967-.997-1.189a4.502 4.502 0 01-.501-.29c-.417-.278-.97-.423-1.53-.27l-1.102.303c-.11.03-.175-.016-.195-.046a6.492 6.492 0 01-.573-.989c-.014-.031-.022-.11.059-.19l.815-.806c.411-.406.562-.957.53-1.456a4.587 4.587 0 010-.582c.032-.499-.119-1.05-.53-1.456l-.815-.806c-.08-.08-.073-.159-.059-.19a6.44 6.44 0 01.573-.99c.02-.029.086-.074.195-.045l1.103.303c.559.153 1.112.008 1.529-.27.16-.107.327-.204.5-.29.449-.222.851-.628.998-1.189l.289-1.105c.029-.11.101-.143.137-.146zM8 0c-.236 0-.47.01-.701.03-.743.065-1.29.615-1.458 1.261l-.29 1.106c-.017.066-.078.158-.211.224a5.994 5.994 0 00-.668.386c-.123.082-.233.117-.3.1L3.27 2.801c-.635-.175-1.322.017-1.768.681A7.963 7.963 0 00.767 4.905c-.324.627-.2 1.358.246 1.8l.816.806c.049.048.098.153.088.313a6.088 6.088 0 000 .352c.01.16-.04.265-.088.313l-.816.806c-.446.443-.57 1.173-.246 1.8a7.96 7.96 0 00.736 1.323c.446.664 1.133.856 1.768.681l1.103-.303c.066-.018.177.018.3.1.22.147.447.28.668.386.133.066.194.158.212.224l.289 1.106c.169.646.715 1.196 1.458 1.26a8.094 8.094 0 001.402 0c.743-.064 1.29-.614 1.458-1.26l.29-1.106c.017-.066.078-.158.211-.224a5.98 5.98 0 00.668-.386c.123-.082.233-.117.3-.1l1.102.302c.635.175 1.322-.017 1.768-.68a7.96 7.96 0 00.736-1.324c.324-.627.2-1.357-.246-1.8l-.816-.805c-.048-.048-.098-.153-.088-.313a6.15 6.15 0 000-.352c-.01-.16.04-.265.088-.313l.816-.806c.446-.443.57-1.173.246-1.8a7.963 7.963 0 00-.736-1.323c-.446-.664-1.133-.856-1.768-.681l-1.103.303c-.066.018-.176-.018-.3-.1a5.99 5.99 0 00-.667-.386c-.133-.066-.194-.158-.212-.224L10.16 1.29C9.99.645 9.444.095 8.701.031A8.094 8.094 0 008 0zm1.5 8a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM11 8a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<!-- Close button -->
<button
class="text-[#8b949e] hover:text-[#e0e0e0] transition-colors cursor-pointer"
title="Close panel (Ctrl+Shift+K)"
@click="store.togglePanel()"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
</svg>
</button>
</div>
</div>
<!-- Unauthenticated state -->
<div
v-if="!store.isAuthenticated"
class="flex-1 flex flex-col items-center justify-center px-6"
>
<span class="text-4xl mb-4">{{ ghostIcon }}</span>
<p class="text-sm text-[#8b949e] text-center mb-4">
Connect to Claude to activate the XO AI copilot.
</p>
<button
class="px-4 py-2 text-sm font-medium text-white bg-[#1f6feb] hover:bg-[#388bfd] rounded transition-colors cursor-pointer"
@click="store.startLogin()"
>
Connect to Claude
</button>
</div>
<!-- Message list -->
<div
v-else
ref="messageListRef"
class="flex-1 overflow-y-auto px-3 py-3"
>
<!-- Empty state -->
<div
v-if="store.messages.length === 0"
class="flex flex-col items-center justify-center h-full text-center"
>
<span class="text-3xl mb-3 opacity-40">{{ ghostIcon }}</span>
<p class="text-sm text-[#8b949e]">XO standing by.</p>
<p class="text-xs text-[#484f58] mt-1">Send a message to begin.</p>
</div>
<!-- Messages -->
<CopilotMessage
v-for="msg in store.messages"
:key="msg.id"
:message="msg"
:is-streaming="store.isStreaming && msg.id === lastAssistantId"
/>
</div>
<!-- Input area (authenticated only) -->
<div
v-if="store.isAuthenticated"
class="shrink-0 border-t border-[#30363d] p-3 bg-[#161b22]"
>
<div class="flex gap-2">
<textarea
ref="inputRef"
v-model="inputText"
:disabled="store.isStreaming"
placeholder="Message the XO..."
rows="1"
class="flex-1 resize-none px-2.5 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] placeholder-[#484f58] outline-none focus:border-[#58a6ff] transition-colors disabled:opacity-50"
@keydown="handleInputKeydown"
@input="autoResize"
/>
<button
class="shrink-0 px-2.5 py-1.5 rounded transition-colors cursor-pointer"
:class="
canSend
? 'bg-[#1f6feb] hover:bg-[#388bfd] text-white'
: 'bg-[#21262d] text-[#484f58] cursor-not-allowed'
"
:disabled="!canSend"
title="Send message"
@click="handleSend"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M.989 8 .064 2.68a1.342 1.342 0 0 1 1.85-1.462l13.402 5.744a1.13 1.13 0 0 1 0 2.076L1.913 14.782a1.343 1.343 0 0 1-1.85-1.463L.99 8Zm.603-5.108L2.18 7.25h4.57a.75.75 0 0 1 0 1.5H2.18l-.588 4.358L14.233 8 1.592 2.892Z" />
</svg>
</button>
</div>
<!-- Streaming indicator -->
<div v-if="store.isStreaming" class="flex items-center gap-1.5 mt-1.5 text-[10px] text-[#3fb950]">
<span class="w-1.5 h-1.5 rounded-full bg-[#3fb950] animate-pulse" />
XO is responding...
</div>
</div>
<!-- Settings modal -->
<CopilotSettings v-if="store.showSettings" @close="store.showSettings = false" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import { useCopilotStore } from "@/stores/copilot.store";
import { useCopilot } from "@/composables/useCopilot";
import CopilotMessage from "./CopilotMessage.vue";
import CopilotSettings from "./CopilotSettings.vue";
const store = useCopilotStore();
const { processMessage } = useCopilot();
const panelWidth = 320;
const inputText = ref("");
const inputRef = ref<HTMLTextAreaElement | null>(null);
const messageListRef = ref<HTMLDivElement | null>(null);
/** Short model name for header display. */
const modelShort = computed(() => {
const m = store.model;
if (m.includes("sonnet")) return "sonnet";
if (m.includes("opus")) return "opus";
if (m.includes("haiku")) return "haiku";
return m;
});
/** Token formatter. */
function formatTokens(n: number): string {
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
return String(n);
}
/** ID of the last assistant message (for streaming cursor). */
const lastAssistantId = computed(() => {
for (let i = store.messages.length - 1; i >= 0; i--) {
if (store.messages[i].role === "assistant") return store.messages[i].id;
}
return null;
});
/** Whether the send button should be active. */
const canSend = computed(() => inputText.value.trim().length > 0 && !store.isStreaming);
/** Send the message. */
async function handleSend(): Promise<void> {
const text = inputText.value.trim();
if (!text || store.isStreaming) return;
inputText.value = "";
resetTextareaHeight();
store.sendMessage(text);
await nextTick();
scrollToBottom();
await processMessage(text);
}
/** Handle keyboard shortcuts in the input area. */
function handleInputKeydown(event: KeyboardEvent): void {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
}
/** Auto-resize textarea based on content. */
function autoResize(): void {
const el = inputRef.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}
/** Reset textarea height back to single line. */
function resetTextareaHeight(): void {
const el = inputRef.value;
if (!el) return;
el.style.height = "auto";
}
/** Scroll the message list to the bottom. */
function scrollToBottom(): void {
const el = messageListRef.value;
if (!el) return;
el.scrollTop = el.scrollHeight;
}
/** Auto-scroll when messages change or streaming content updates. */
watch(
() => store.messages.length,
async () => {
await nextTick();
scrollToBottom();
},
);
watch(
() => {
const msgs = store.messages;
if (msgs.length === 0) return "";
return msgs[msgs.length - 1].content;
},
async () => {
await nextTick();
scrollToBottom();
},
);
// The ghost icon used in multiple places
const ghostIcon = "\uD83D\uDC7B";
</script>