feat: add workspace companion console MVP

This commit is contained in:
2026-06-26 17:01:30 +09:00
parent f39036032b
commit f3ad9c96b3
9 changed files with 1309 additions and 1 deletions
+237
View File
@@ -440,3 +440,240 @@
white-space: pre-wrap;
}
}
.console-shell {
gap: 1rem;
}
.console-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.console-status {
display: grid;
justify-items: end;
gap: 0.25rem;
min-width: 10rem;
padding: 0.75rem 0.9rem;
border-radius: 16px;
background: #ecfeff;
border: 1px solid #a5f3fc;
color: #0f766e;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.78rem;
}
.console-status small {
text-transform: none;
letter-spacing: 0;
color: #475569;
font-weight: 600;
}
.console-status[data-state='busy'] {
background: #fff7ed;
border-color: #fed7aa;
color: #c2410c;
}
.console-status[data-state='error'],
.console-status[data-state='timeout'] {
background: #fef2f2;
border-color: #fecaca;
color: #b91c1c;
}
.console-status[data-state='cancelled'],
.console-status[data-state='rejected'] {
background: #f8fafc;
border-color: #cbd5e1;
color: #475569;
}
.console-transport {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.75rem;
}
.console-transport div {
display: grid;
gap: 0.15rem;
}
.console-transport dt {
margin: 0;
color: #64748b;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.console-transport dd {
margin: 0;
color: #0f172a;
font-weight: 700;
}
.console-transport p {
grid-column: 1 / -1;
margin: 0;
color: #475569;
line-height: 1.5;
}
.console-diagnostics {
display: grid;
gap: 0.4rem;
}
.diagnostic {
margin: 0;
padding: 0.55rem 0.7rem;
border-radius: 10px;
background: #eff6ff;
color: #1d4ed8;
font-size: 0.86rem;
}
.diagnostic.warning,
.diagnostic.warn {
background: #fff7ed;
color: #c2410c;
}
.diagnostic.error {
background: #fef2f2;
color: #b91c1c;
}
.transcript-card,
.composer-card {
display: grid;
gap: 1rem;
}
.transcript-list {
display: grid;
gap: 0.85rem;
list-style: none;
margin: 0;
padding: 0;
}
.transcript-item {
display: grid;
gap: 0.45rem;
padding: 0.85rem 1rem;
border-radius: 16px;
border: 1px solid #dbeafe;
background: #f8fafc;
}
.transcript-item.user {
margin-left: min(8vw, 4rem);
background: #eff6ff;
border-color: #bfdbfe;
}
.transcript-item.assistant {
margin-right: min(8vw, 4rem);
background: #f0fdf4;
border-color: #bbf7d0;
}
.message-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
color: #64748b;
font-size: 0.78rem;
}
.message-meta strong {
color: #0f172a;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.message-meta time {
margin-left: auto;
}
.transcript-item p {
margin: 0;
white-space: pre-wrap;
line-height: 1.55;
color: #1f2937;
}
.empty-state {
margin: 0;
padding: 1rem;
border-radius: 14px;
background: #f8fafc;
color: #64748b;
}
.composer-card label {
font-weight: 800;
color: #0f172a;
}
.composer-card textarea {
width: 100%;
min-height: 8rem;
resize: vertical;
border: 1px solid #cbd5e1;
border-radius: 14px;
padding: 0.85rem 1rem;
font: inherit;
color: #0f172a;
background: #ffffff;
}
.composer-card textarea:disabled {
background: #f8fafc;
color: #64748b;
}
.composer-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.7rem;
}
.composer-actions span {
margin-right: auto;
color: #64748b;
font-size: 0.85rem;
}
.composer-actions button {
border: 0;
border-radius: 999px;
padding: 0.65rem 1rem;
background: #2563eb;
color: #ffffff;
font-weight: 800;
cursor: pointer;
}
.composer-actions button.secondary {
background: #e2e8f0;
color: #334155;
}
.composer-actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@@ -0,0 +1,20 @@
<script lang="ts">
type Props = {
currentPath?: string;
};
let { currentPath = '/' }: Props = $props();
const active = $derived(currentPath === '/console');
</script>
<section class="sidebar-section" aria-labelledby="companion-console-heading">
<div class="section-heading-row">
<h2 id="companion-console-heading">Console</h2>
<span class="section-count">MVP</span>
</div>
<a class:active class="nav-item" href="/console" aria-current={active ? 'page' : undefined}>
<span class="item-title">Companion Console</span>
<span class="item-meta">status · transcript · send</span>
</a>
</section>
@@ -1,4 +1,5 @@
<script lang="ts">
import CompanionNavSection from './CompanionNavSection.svelte';
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
@@ -41,6 +42,7 @@
</header>
<nav class="sidebar-sections" aria-label="Workspace sections">
<CompanionNavSection {currentPath} />
<RepositoriesNavSection {workspace} {currentPath} />
<ObjectivesNavSection {currentPath} />
<WorkersNavSection />
@@ -16,6 +16,7 @@ export type WorkspaceResponse = {
extension_points: {
event_stream: ExtensionPoint;
host_worker_bridge: ExtensionPoint;
companion_console: ExtensionPoint;
};
};
@@ -209,3 +210,57 @@ export type ObjectiveListResponse = {
invalid_records: InvalidProjectRecord[];
record_authority: string;
};
export type CompanionState =
| 'ready'
| 'busy'
| 'error'
| 'timeout'
| 'cancelled'
| 'accepted'
| 'rejected';
export type CompanionTransportSummary = {
kind: string;
completion: string;
limitation: string;
};
export type CompanionStatusResponse = {
state: CompanionState;
worker?: Worker | null;
transport: CompanionTransportSummary;
diagnostics: Diagnostic[];
};
export type CompanionTranscriptItem = {
sequence: number;
role: 'user' | 'assistant' | 'system' | string;
content: string;
created_at: string;
source: string;
status: string;
};
export type CompanionTranscriptProjection = {
state: CompanionState;
start: number;
limit: number;
total_items: number;
next_start?: number | null;
items: CompanionTranscriptItem[];
diagnostics: Diagnostic[];
};
export type CompanionMessageRequest = {
content: string;
};
export type CompanionMessageResponse = {
state: CompanionState;
worker?: Worker | null;
user_item?: CompanionTranscriptItem | null;
assistant_item?: CompanionTranscriptItem | null;
transcript: CompanionTranscriptProjection;
diagnostics: Diagnostic[];
};
@@ -0,0 +1,281 @@
<script lang="ts">
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
import type {
CompanionMessageResponse,
CompanionState,
CompanionStatusResponse,
CompanionTranscriptItem,
CompanionTranscriptProjection,
Diagnostic,
WorkspaceResponse
} from '$lib/workspace-sidebar/types';
let workspace = $state<WorkspaceResponse | null>(null);
let workspaceError = $state<string | null>(null);
let status = $state<CompanionStatusResponse | null>(null);
let transcript = $state<CompanionTranscriptProjection | null>(null);
let draft = $state('');
let operationState = $state<CompanionState>('ready');
let error = $state<string | null>(null);
let timeoutNotice = $state<string | null>(null);
let requestId = 0;
const currentPath = '/console';
const messages = $derived(transcript?.items ?? []);
const diagnostics = $derived(mergeDiagnostics(status?.diagnostics ?? [], transcript?.diagnostics ?? []));
const sending = $derived(operationState === 'busy');
const canSend = $derived(draft.trim().length > 0 && !sending);
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`GET ${path} failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
async function postJson<T>(path: string, body: unknown, timeoutMs = 45_000): Promise<T> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal
});
if (!response.ok) {
let detail = '';
try {
detail = await response.text();
} catch {
detail = '';
}
throw new Error(`POST ${path} failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
return response.json() as Promise<T>;
} catch (requestError) {
if (requestError instanceof DOMException && requestError.name === 'AbortError') {
operationState = 'timeout';
timeoutNotice = 'Workspace server request timed out before a Companion response arrived.';
}
throw requestError;
} finally {
window.clearTimeout(timeout);
}
}
async function loadWorkspace() {
workspaceError = null;
try {
workspace = await getJson<WorkspaceResponse>('/api/workspace');
} catch (loadError) {
workspaceError = loadError instanceof Error ? loadError.message : String(loadError);
workspace = null;
}
}
async function loadCompanion() {
error = null;
timeoutNotice = null;
try {
const [nextStatus, nextTranscript] = await Promise.all([
getJson<CompanionStatusResponse>('/api/companion/status'),
getJson<CompanionTranscriptProjection>('/api/companion/transcript?limit=200')
]);
status = nextStatus;
transcript = nextTranscript;
operationState = nextStatus.state === 'error' ? 'error' : 'ready';
} catch (loadError) {
error = loadError instanceof Error ? loadError.message : String(loadError);
operationState = 'error';
}
}
async function sendMessage(event: SubmitEvent) {
event.preventDefault();
const content = draft.trim();
if (!content || sending) {
return;
}
const currentRequest = ++requestId;
error = null;
timeoutNotice = null;
operationState = 'busy';
try {
const response = await postJson<CompanionMessageResponse>('/api/companion/messages', { content });
if (currentRequest !== requestId) {
return;
}
operationState = response.state;
transcript = response.transcript;
if (response.worker || status) {
status = {
state: response.state === 'accepted' ? 'ready' : response.state,
worker: response.worker ?? status?.worker ?? null,
transport: status?.transport ?? {
kind: 'providerless_backend_internal',
completion: 'synchronous_request_response',
limitation: 'Companion transport metadata was not available during this response.'
},
diagnostics: response.diagnostics
};
}
if (response.state === 'accepted') {
draft = '';
operationState = 'ready';
} else if (response.state === 'busy') {
error = 'Companion is busy with another message.';
} else if (response.state === 'rejected') {
error = diagnosticsToText(response.diagnostics) || 'Companion rejected the message.';
} else if (response.state === 'error') {
error = diagnosticsToText(response.diagnostics) || 'Companion returned an error.';
}
} catch (sendError) {
if (currentRequest !== requestId) {
return;
}
if (operationState !== 'timeout') {
operationState = 'error';
}
error = sendError instanceof Error ? sendError.message : String(sendError);
}
}
async function cancelMessage() {
++requestId;
operationState = 'cancelled';
try {
const response = await postJson<CompanionMessageResponse>('/api/companion/cancel', { reason: 'browser_cancel' }, 10_000);
transcript = response.transcript;
status = status
? { ...status, state: response.state, diagnostics: response.diagnostics }
: status;
} catch (cancelError) {
error = cancelError instanceof Error ? cancelError.message : String(cancelError);
operationState = 'error';
}
}
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
return groups.flat();
}
function diagnosticsToText(items: Diagnostic[]): string {
return items.map((item) => `${item.severity}: ${item.message}`).join('\n');
}
function itemClass(item: CompanionTranscriptItem): string {
if (item.role === 'assistant') {
return 'assistant';
}
if (item.role === 'user') {
return 'user';
}
return 'system';
}
$effect(() => {
void loadWorkspace();
void loadCompanion();
});
</script>
<svelte:head>
<title>Companion Console · Yoi Workspace</title>
<meta name="description" content="Workspace Companion Web Console MVP" />
</svelte:head>
<div class="workspace-layout">
<WorkspaceSidebar {workspace} {workspaceError} {currentPath} />
<main class="shell console-shell">
<section class="console-header card">
<div>
<p class="eyebrow">Backend-internal Companion</p>
<h2>Companion Console</h2>
<p class="section-note">
Browser traffic stays behind Workspace API projections. No Worker socket, session path,
runtime credential, or local session file is exposed to the frontend.
</p>
</div>
<div class="console-status" data-state={operationState}>
<span>{operationState}</span>
{#if status?.worker}
<small>{status.worker.label}</small>
{:else}
<small>worker pending</small>
{/if}
</div>
</section>
{#if status?.transport}
<section class="card console-transport" aria-label="Companion transport">
<div>
<dt>Transport</dt>
<dd>{status.transport.kind}</dd>
</div>
<div>
<dt>Completion</dt>
<dd>{status.transport.completion}</dd>
</div>
<p>{status.transport.limitation}</p>
</section>
{/if}
{#if error || timeoutNotice || diagnostics.length > 0}
<section class="card console-diagnostics" aria-label="Companion diagnostics">
{#if timeoutNotice}
<p class="diagnostic warning">{timeoutNotice}</p>
{/if}
{#if error}
<p class="diagnostic error">{error}</p>
{/if}
{#each diagnostics as diagnostic}
<p class={`diagnostic ${diagnostic.severity}`}>{diagnostic.code}: {diagnostic.message}</p>
{/each}
</section>
{/if}
<section class="card transcript-card" aria-label="Companion transcript">
<div class="runtime-heading">
<h3>Transcript</h3>
<span>{transcript?.total_items ?? 0} items</span>
</div>
{#if messages.length === 0}
<p class="empty-state">No Companion messages yet. Send a message to exercise the backend boundary.</p>
{:else}
<ol class="transcript-list">
{#each messages as message (message.sequence)}
<li class={`transcript-item ${itemClass(message)}`}>
<div class="message-meta">
<strong>{message.role}</strong>
<span>{message.status}</span>
<time datetime={message.created_at}>{message.created_at}</time>
</div>
<p>{message.content}</p>
</li>
{/each}
</ol>
{/if}
</section>
<form class="card composer-card" onsubmit={sendMessage}>
<label for="companion-message">Message Companion</label>
<textarea
id="companion-message"
bind:value={draft}
rows="4"
maxlength="8000"
placeholder="Ask or note something for the backend Companion boundary…"
disabled={sending}
></textarea>
<div class="composer-actions">
<span>{draft.trim().length}/8000</span>
<button type="button" class="secondary" onclick={loadCompanion} disabled={sending}>Refresh</button>
<button type="button" class="secondary" onclick={cancelMessage} disabled={!sending}>Cancel</button>
<button type="submit" disabled={!canSend}>Send</button>
</div>
</form>
</main>
</div>