All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m2s
Replace mock responses in the XO copilot panel with real Wails binding calls to the Go AIService backend: - StartLogin now opens the browser via pkg/browser.OpenURL - SendMessage returns ChatResponse (text + tool call results) instead of bare error, fixing the tool-call accumulation bug in messageLoop - Add GetModel/SetModel methods for frontend model switching - Frontend useCopilot composable calls Go via Call.ByName from @wailsio/runtime, with conversation auto-creation, auth checks, and error display in the chat panel - Store defaults to isAuthenticated=false; panel checks auth on mount - CopilotSettings syncs model changes and logout to the backend Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
250 lines
10 KiB
Vue
250 lines
10 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="handleConnectLogin"
|
|
>
|
|
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, onMounted } 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, checkAuth, startLogin, getModel } = useCopilot();
|
|
|
|
// Check auth status and load model on mount
|
|
onMounted(async () => {
|
|
await checkAuth();
|
|
await getModel();
|
|
});
|
|
|
|
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;
|
|
});
|
|
|
|
/** Trigger OAuth login from the inline panel button. */
|
|
async function handleConnectLogin(): Promise<void> {
|
|
await startLogin();
|
|
store.isAuthenticated = true;
|
|
}
|
|
|
|
/** 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>
|