<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
connectEventStream,
fetchHealth,
sendMessage,
checkAuthStatus,
login,
type WebEvent
} from '$lib/events.js';
import { fetchConfig } from '$lib/api.js';
import { appStore } from '$lib/store.svelte.js';
import { layoutStore } from '$lib/stores/layout.svelte.js';
import { projectsStore } from '$lib/stores/projects.svelte.js';
import { sessionsStore } from '$lib/stores/sessions.svelte.js';
import { agentGraphStore } from '$lib/stores/agent-graph.svelte.js';
import Shell from '$lib/components/layout/Shell.svelte';
import ChatArea from '$lib/components/chat/ChatArea.svelte';
import AgentGraph from '$lib/components/graph/AgentGraph.svelte';
import AgentNodePopup from '$lib/components/graph/AgentNodePopup.svelte';
const API_BASE = typeof window !== 'undefined' ? window.location.origin : 'http://127.0.0.1:3080';
let usernameValue = $state('collet');
let passwordValue = $state('');
let disconnectSSE: (() => void) | undefined;
let loginLoading = $state(false);
let showGraph = $state(false);
// Load session messages into chat when a session is selected
$effect(() => {
const detail = sessionsStore.sessionDetail;
if (detail && detail.messages) {
const msgs = detail.messages
.filter((m) => (m.role === 'user' || m.role === 'assistant') && m.content)
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content as string }));
appStore.loadMessages(msgs);
}
});
// Auto-flush message queue: when agent finishes and queue has items, send next
$effect(() => {
if (!appStore.agentActive && appStore.messageQueue.length > 0) {
const next = appStore.dequeueMessage();
if (next) {
handleSubmit(next, layoutStore.mode);
}
}
});
// Show graph when hive agents are active
$effect(() => {
if (agentGraphStore.agents.size > 0) {
showGraph = true;
}
});
function handleEvent(ev: WebEvent) {
appStore.handleEvent(ev);
agentGraphStore.handleEvent(ev as WebEvent);
}
function connectSSE() {
disconnectSSE?.();
disconnectSSE = connectEventStream(
API_BASE,
handleEvent,
() => {
appStore.connected = false;
},
appStore.authToken ?? undefined
);
}
onMount(async () => {
try {
const [health, authStatus] = await Promise.all([
fetchHealth(API_BASE),
checkAuthStatus(API_BASE)
]);
appStore.connected = true;
appStore.serverVersion = health.version;
appStore.authRequired = authStatus.auth_required;
} catch {
appStore.connected = false;
}
if (!appStore.authRequired) {
connectSSE();
await Promise.all([
projectsStore.load(),
loadConfig()
]);
}
});
onDestroy(() => {
disconnectSSE?.();
});
async function loadConfig(token?: string) {
try {
const cfg = await fetchConfig(token);
appStore.model = cfg.model;
appStore.contextMax = cfg.context_max_tokens;
} catch {
// ignore – config is optional enhancement
}
}
async function handleLogin() {
const pw = passwordValue.trim();
if (!pw || loginLoading) return;
loginLoading = true;
appStore.authError = null;
try {
const res = await login(API_BASE, usernameValue.trim() || 'collet', pw);
if (res.token) {
appStore.authToken = res.token;
appStore.authError = null;
passwordValue = '';
connectSSE();
await Promise.all([
projectsStore.load(res.token),
loadConfig(res.token)
]);
} else {
appStore.authError = res.error ?? 'Login failed';
}
} catch {
appStore.authError = 'Connection failed';
} finally {
loginLoading = false;
}
}
async function handleSubmit(msg: string, mode: string) {
appStore.addUserMessage(msg);
try {
const res = await sendMessage(API_BASE, msg, mode, appStore.authToken ?? undefined);
if (!res.accepted) {
appStore.handleEvent({ type: 'error', message: res.reason ?? 'Request rejected' });
appStore.agentActive = false;
}
} catch (e) {
console.error('Failed to send message:', e);
appStore.handleEvent({ type: 'error', message: 'Failed to send message. Please check your connection.' });
appStore.removeLastMessage();
}
}
function toggleGraph() {
showGraph = !showGraph;
}
</script>
{#if appStore.authRequired && !appStore.authToken}
<div class="login-screen">
<div class="login-box">
<span class="logo">collet</span>
<p class="login-hint">Password required</p>
{#if appStore.authError}
<div class="login-error">{appStore.authError}</div>
{/if}
<form class="login-form" onsubmit={(e) => { e.preventDefault(); handleLogin(); }}>
<input
type="text"
class="login-input"
bind:value={usernameValue}
placeholder="Username"
disabled={loginLoading}
/>
<input
type="password"
class="login-input"
bind:value={passwordValue}
placeholder="Password"
disabled={loginLoading}
/>
<button class="login-btn" type="submit" disabled={loginLoading}>
{loginLoading ? '…' : 'Sign in'}
</button>
</form>
</div>
</div>
{:else}
<Shell onsubmit={handleSubmit}>
{#if appStore.messages.length === 0 && !appStore.streamingTokens}
<div class="welcome">
<div class="welcome-icon">◆</div>
<h2 class="welcome-title">collet</h2>
<p class="welcome-path">{API_BASE}</p>
{#if appStore.connected}
<p class="welcome-status">
<span class="dot connected"></span>
Connected
{#if appStore.serverVersion} · v{appStore.serverVersion}{/if}
{#if appStore.model} · {appStore.model}{/if}
</p>
<p class="welcome-hint">Type a message, <kbd>/</kbd> for commands, <kbd>@</kbd> for agents & files</p>
{:else}
<p class="welcome-status">
<span class="dot"></span>
Disconnected
</p>
{/if}
</div>
{:else}
<div class="content-area">
<!-- Graph toggle button -->
{#if agentGraphStore.agents.size > 0}
<button class="graph-toggle" onclick={toggleGraph} aria-label="Toggle agent graph">
{showGraph ? '💬' : '◈'} {#if !showGraph}{agentGraphStore.agents.size} agents{/if}
</button>
{/if}
{#if showGraph && agentGraphStore.agents.size > 0}
<div class="graph-panel" class:fullscreen={showGraph}>
<div class="graph-header">
<span class="graph-title">◈ Agent Graph</span>
<span class="graph-stats">
{agentGraphStore.agents.size} agents ·
{[...agentGraphStore.agents.values()].filter(a => a.status === 'running').length} active
</span>
<button class="graph-minimize" onclick={toggleGraph}>✕</button>
</div>
<div class="graph-canvas">
<AgentGraph />
</div>
</div>
{/if}
<ChatArea />
</div>
{/if}
</Shell>
<!-- Agent detail popup (global overlay) -->
<AgentNodePopup />
{/if}
<style>
/* Login screen */
.login-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: var(--bg);
}
.login-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius, 8px);
min-width: 300px;
}
.logo {
color: var(--accent);
font-weight: 700;
font-size: 1.25rem;
}
.login-hint {
color: var(--fg-dim);
font-size: 0.875rem;
margin: 0;
}
.login-error {
color: var(--red);
font-size: 0.875rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.login-input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius, 8px);
color: var(--fg);
font-family: inherit;
font-size: 1rem;
padding: 0.6rem 0.75rem;
outline: none;
}
.login-input:focus { border-color: var(--accent); }
.login-input:disabled { opacity: 0.5; }
.login-btn {
background: var(--accent);
color: var(--bg);
border: none;
border-radius: var(--radius, 8px);
padding: 0.6rem 1rem;
font-family: inherit;
font-size: 1rem;
cursor: pointer;
}
.login-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* Welcome */
.welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.625rem;
text-align: center;
padding: 3rem 1rem;
height: 100%;
}
.welcome-icon {
font-size: 2.5rem;
color: var(--accent);
line-height: 1;
margin-bottom: 0.25rem;
}
.welcome-title {
font-size: 1.375rem;
font-weight: 700;
color: var(--fg-bright);
margin: 0;
letter-spacing: 0.04em;
}
.welcome-path {
font-size: 0.8125rem;
color: var(--fg-dim);
margin: 0;
}
.welcome-status {
font-size: 0.8125rem;
color: var(--fg-dim);
margin: 0;
display: flex;
align-items: center;
gap: 0.25rem;
}
.welcome-hint {
font-size: 0.75rem;
color: var(--fg-dim);
margin: 0;
}
.welcome-hint kbd {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0.0625rem 0.3rem;
font-family: inherit;
font-size: inherit;
}
.dot {
display: inline-block;
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: var(--fg-dim);
flex-shrink: 0;
}
.dot.connected { background: var(--green); }
/* Content area with graph */
.content-area {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
}
.graph-toggle {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 20;
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--fg);
padding: 0.25rem 0.5rem;
border-radius: var(--radius, 6px);
font-size: 0.75rem;
font-family: var(--font-mono);
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
}
.graph-toggle:hover {
background: var(--bg-elevated);
border-color: var(--accent);
}
.graph-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 50%;
min-width: 400px;
z-index: 10;
background: var(--bg);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.graph-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
background: var(--bg-surface);
flex-shrink: 0;
}
.graph-title {
color: var(--accent);
font-weight: 700;
font-size: 0.85rem;
}
.graph-stats {
color: var(--fg-dim);
font-size: 0.7rem;
flex: 1;
}
.graph-minimize {
all: unset;
cursor: pointer;
color: var(--fg-dim);
font-size: 0.875rem;
padding: 0.125rem 0.25rem;
}
.graph-minimize:hover { color: var(--fg); }
.graph-canvas {
flex: 1;
min-height: 0;
}
@media (max-width: 768px) {
.graph-panel {
width: 100%;
min-width: 0;
}
}
</style>