wraith/frontend/src/components/copilot/CopilotToolViz.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

131 lines
5.2 KiB
Vue

<template>
<div
class="rounded border overflow-hidden my-2 text-xs"
:class="borderClass"
>
<!-- Tool call header clickable to expand -->
<button
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-[#21262d] hover:bg-[#282e36] transition-colors cursor-pointer text-left"
@click="expanded = !expanded"
>
<!-- Color bar -->
<span class="w-0.5 h-4 rounded-full shrink-0" :class="barColorClass" />
<!-- Status indicator -->
<span v-if="toolCall.status === 'pending'" class="shrink-0">
<svg class="w-3.5 h-3.5 animate-spin text-[#8b949e]" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" class="opacity-25" />
<path d="M4 12a8 8 0 018-8" stroke="currentColor" stroke-width="3" stroke-linecap="round" class="opacity-75" />
</svg>
</span>
<span v-else-if="toolCall.status === 'done'" class="shrink-0 text-[#3fb950]">
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z" />
</svg>
</span>
<span v-else class="shrink-0 text-[#f85149]">
<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>
</span>
<!-- Icon + summary -->
<span class="text-[#e0e0e0] truncate">{{ icon }} {{ summary }}</span>
<!-- Expand chevron -->
<svg
class="w-3 h-3 ml-auto shrink-0 text-[#8b949e] transition-transform"
:class="{ 'rotate-90': expanded }"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z" />
</svg>
</button>
<!-- Expanded details -->
<div v-if="expanded" class="border-t border-[#30363d] bg-[#161b22]">
<div class="px-2.5 py-2">
<div class="text-[10px] text-[#8b949e] mb-1 uppercase tracking-wide">Input</div>
<pre class="text-[11px] text-[#e0e0e0] whitespace-pre-wrap break-all font-mono bg-[#0d1117] rounded p-2">{{ formattedInput }}</pre>
</div>
<div v-if="toolCall.result !== undefined" class="px-2.5 pb-2">
<div class="text-[10px] text-[#8b949e] mb-1 uppercase tracking-wide">Result</div>
<pre class="text-[11px] text-[#e0e0e0] whitespace-pre-wrap break-all font-mono bg-[#0d1117] rounded p-2">{{ formattedResult }}</pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import type { ToolCall } from "@/stores/copilot.store";
const props = defineProps<{
toolCall: ToolCall;
}>();
const expanded = ref(false);
/** Border color class based on tool call status. */
const borderClass = computed(() => {
if (props.toolCall.status === "error") return "border-[#f85149]/30";
return "border-[#30363d]";
});
/** Left color bar based on tool type. */
const barColorClass = computed(() => {
const name = props.toolCall.name;
if (name.startsWith("terminal_") || name === "terminal_cwd") return "bg-[#3fb950]";
if (name.startsWith("sftp_")) return "bg-[#e3b341]";
if (name.startsWith("rdp_")) return "bg-[#1f6feb]";
return "bg-[#8b949e]";
});
/** Icon for the tool type. */
const icon = computed(() => {
const name = props.toolCall.name;
const icons: Record<string, string> = {
terminal_write: "\u27E9",
terminal_read: "\u25CE",
terminal_cwd: "\u27E9",
sftp_read: "\uD83D\uDCC4",
sftp_write: "\uD83D\uDCBE",
rdp_screenshot: "\uD83D\uDCF8",
rdp_click: "\uD83D\uDDB1",
rdp_type: "\u2328",
list_sessions: "\u2261",
connect_session: "\u2192",
disconnect_session: "\u2717",
};
return icons[name] ?? "\u2022";
});
/** One-line summary of the tool call. */
const summary = computed(() => {
const name = props.toolCall.name;
const input = props.toolCall.input;
if (name === "terminal_write") return `Typed \`${input.text}\` in session ${input.sessionId}`;
if (name === "terminal_read") return `Read ${input.lines ?? "?"} lines from session ${input.sessionId}`;
if (name === "terminal_cwd") return `Working directory in session ${input.sessionId}`;
if (name === "sftp_read") return `Read ${input.path}`;
if (name === "sftp_write") return `Wrote ${input.path}`;
if (name === "rdp_screenshot") return `Screenshot from session ${input.sessionId}`;
if (name === "rdp_click") return `Clicked at (${input.x}, ${input.y})`;
if (name === "rdp_type") return `Typed \`${input.text}\``;
if (name === "list_sessions") return "List active sessions";
if (name === "connect_session") return `Connect to session ${input.sessionId}`;
if (name === "disconnect_session") return `Disconnect session ${input.sessionId}`;
return name;
});
const formattedInput = computed(() => JSON.stringify(props.toolCall.input, null, 2));
const formattedResult = computed(() =>
props.toolCall.result !== undefined
? JSON.stringify(props.toolCall.result, null, 2)
: "",
);
</script>