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>
131 lines
5.2 KiB
Vue
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>
|