feat(frontend): full remote ui with mock data

This commit is contained in:
Shivering Ocelot committed 2026-09-01 22:20:01 -05:00
1 parent 143bca8a8b
commit 0705f4abc5
1 file changed
+313 -60
+313 -60
View File
@@ -1,11 +1,174 @@
<script setup>
import TitleBar from '../components/TitleBar.vue';
import { ref } from 'vue';
import { ref, reactive, computed, onMounted } from 'vue';
import * as backend from '../backend';
import * as ui from '../ui';
/** Connected external tool remotes (initialized with mock data) */
const remotes = ref([
{
name: 'weather-service',
connected: true,
functions: [
{
name: 'get_forecast',
description: 'Retrieve weather forecast for a location.',
parameters: {
type: 'object',
properties: {
city: { type: 'string', description: 'City name or code.' },
days: { type: 'number', description: 'Number of days ahead.' }
},
required: ['city']
}
},
{
name: 'search_storms',
description: 'Find active storm systems near coordinates.',
parameters: {
type: 'object',
properties: {
lat: { type: 'number', description: 'Latitude.' },
lon: { type: 'number', description: 'Longitude.' },
radius_km: { type: 'number', description: 'Search radius.' }
},
required: ['lat', 'lon']
}
}
],
agentIds: []
},
{
name: 'code-repo-indexer',
connected: true,
functions: [
{
name: 'search_codebase',
description: 'Search repository code by query.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search text.' },
lang: { type: 'string', description: 'Language filter.' },
path_regex: { type: 'string', description: 'Path pattern.' }
},
required: ['query']
}
},
{
name: 'find_symbol',
description: 'Locate a symbol definition in the codebase.',
parameters: {
type: 'object',
properties: {
symbol_name: { type: 'string', description: 'Name of the symbol.' },
file_path: { type: 'string', description: 'Restrict to a file.' }
},
required: ['symbol_name']
}
}
],
agentIds: []
}
]);
/** Tracks expanded remote panels using a Set to support multiple open sections */
const expandedRemoteIds = ref(new Set());
/** Currently selected remote name for invoking a function */
const activeRemoteName = ref('');
/** Currently selected function spec inside the active remote */
const activeFunction = ref(null);
/** JSON string containing arguments for the active function */
const activeArgsJson = ref('{}');
/** Parsed result object of the last successful call (null until called) */
const callResult = ref(null);
/** Error message from the last call attempt (null when no error) */
const callError = ref('');
/** List of locally persisted remotes managed via localStorage */
const localRemoteList = ref([]);
/** Key used to serialize/deserialize local remotes from browser storage */
const STORAGE_KEY = 'local_remotes';
onMounted(() => {
// fetchRemotes(); // Disabled — using hardcoded mock data for now
});
/** Toggle expansion of a remote detail panel */
function toggleRemote(name) {
// Create a new Set instance to ensure Vue detects the change
const newSet = new Set(expandedRemoteIds.value);
if (newSet.has(name)) {
newSet.delete(name);
} else {
newSet.add(name);
}
expandedRemoteIds.value = newSet;
}
/** Set the active remote and expose its function list to the caller */
function selectRemote(name) {
const found = remotes.value.find(r => r.name === name);
if (!found) return;
if (!activeFunction.value && found.functions.length) {
selectFunction(found, found.functions[0]);
} else {
resetCallState();
}
}
/** Activate a specific function for invocation */
function selectFunction(remote, fn) {
if (remote && fn) {
activeRemoteName.value = remote.name;
activeFunction.value = fn;
} else {
activeRemoteName.value = null;
activeFunction.value = null;
}
resetCallState();
}
/** Clear call result/error and reinitialize args JSON */
function resetCallState() {
callResult.value = null;
callError.value = '';
activeArgsJson.value = '{}';
}
/** Execute a call against the active remote & function */
async function doCall() {
callResult.value = null;
callError.value = '';
if (!activeRemoteName.value || !activeFunction.value) {
callError.value = 'Select a remote and function first.';
return;
}
let argsParsed;
try {
argsParsed = activeArgsJson.value.trim() ? JSON.parse(activeArgsJson.value) : {};
} catch (e) {
callError.value = 'Invalid JSON args: ' + e.message;
return;
}
try {
callResult.value = await backend.sendAction('remote-call', {
remotename: activeRemoteName.value,
function: activeFunction.value.name,
args: argsParsed,
});
} catch (e) {
callError.value = e.message || String(e);
}
}
// ---- Local Remotes (unchanged from previous iteration) ----
function loadLocalRemoteList() {
let data = [];
try {
@@ -14,10 +177,7 @@ function loadLocalRemoteList() {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) data = parsed;
}
} catch (e) {
// ignore corrupt storage
}
// Force disconnect all on startup
} catch (e) {}
data.forEach(r => r.connected = false);
localRemoteList.value = data;
}
@@ -45,13 +205,9 @@ function disconnectLocalRemote(remote) {
}
function removeLocalRemote(remote) {
if (remote.connected) {
disconnectLocalRemote(remote);
}
if (remote.connected) disconnectLocalRemote(remote);
const index = localRemoteList.value.indexOf(remote);
if (index > -1) {
localRemoteList.value.splice(index, 1);
}
if (index > -1) localRemoteList.value.splice(index, 1);
saveLocalRemote();
}
@@ -60,62 +216,159 @@ loadLocalRemoteList();
<template>
<main>
<TitleBar>
<h1>Remotes</h1>
</TitleBar>
<div class="remote-list">
<div v-for="(remote, index) in localRemoteList" :key="index" class="remote-entry">
<div v-if="remote.connected" class="view-mode">
<span class="status-text">Connected as: {{ remote.name }}</span>
<button class="btn btn-secondary" @click="disconnectLocalRemote(remote)">Disconnect</button>
</div>
<div v-else class="view-mode">
<input
type="text"
v-model="remote.name"
placeholder="Enter remote name..."
class="input-field"
/>
<button class="btn btn-primary" @click="connectLocalRemote(remote)">Connect</button>
</div>
<button class="btn btn-danger" @click="removeLocalRemote(remote)">Delete</button>
<TitleBar><h1>Remotes</h1></TitleBar>
<!-- ============================== -->
<!-- Active Function Call Panel -->
<!-- ============================== -->
<section v-if="activeRemoteName && activeFunction" class="section">
<div class="section-header">
<h2>Invoke: {{ activeFunction.name }}</h2>
</div>
</div>
<button class="btn btn-primary" @click="createLocalRemote">+ Create Local Remote</button>
<div class="call-layout">
<div class="call-inputs">
<label>Arguments (JSON)</label>
<textarea
v-model="activeArgsJson"
rows="6"
placeholder='{"param": "value"}'
spellcheck="false"
></textarea>
<button class="btn btn-primary" @click="doCall">Execute Call</button>
<button class="btn" @click="selectFunction(null, null)">Cancel</button>
</div>
<div class="call-output">
<div v-if="callResult !== null" class="output-block success">
<strong>Result</strong>
<pre>{{ JSON.stringify(callResult, null, 2) }}</pre>
</div>
<div v-if="callError" class="output-block error">
<strong>Error</strong>
<pre>{{ callError }}</pre>
</div>
<div v-if="callResult === null && callError === ''" class="output-placeholder">
Call will appear here
</div>
</div>
</div>
</section>
<!-- ======================== -->
<!-- External Remotes Section -->
<!-- ======================== -->
<section class="section">
<div class="section-header">
<h2>Remotes</h2>
<span class="count-badge">{{ remotes.length }} registered</span>
</div>
<div v-if="remotes.length === 0" class="empty-state">No remotes connected.</div>
<div v-for="remote in remotes" :key="remote.name" class="card">
<div class="card-header" @click="toggleRemote(remote.name)">
<div class="header-left">
<strong>{{ remote.name }}</strong>
<span class="status-dot" :class="{ active: remote.connected }"></span>
<span class="status-text">{{ remote.connected ? 'Connected' : 'Disconnected' }}</span>
</div>
<span class="expand-icon">{{ expandedRemoteIds.has(remote.name) ? '▼' : '▶' }}</span>
</div>
<div v-if="expandedRemoteIds.has(remote.name)" class="card-body">
<div class="functions-label">Functions (click to activate):</div>
<div class="func-grid">
<button
v-for="(fn, idx) in remote.functions"
:key="idx"
class="func-card"
:class="{ active: activeRemoteName === remote.name && activeFunction?.name === fn.name }"
@click.stop="selectFunction(remote, fn)"
>
<div class="func-name">{{ fn.name }}</div>
<div class="func-desc">{{ fn.description }}</div>
</button>
</div>
</div>
</div>
</section>
<!-- ========================== -->
<!-- Local Remotes Section -->
<!-- ========================== -->
<section class="section">
<h2>Local Remotes</h2>
<div class="remote-list">
<div v-for="(remote, index) in localRemoteList" :key="index" class="remote-entry">
<div v-if="remote.connected" class="view-mode">
<span class="status-text">Connected as: {{ remote.name }}</span>
<button class="btn btn-secondary" @click="disconnectLocalRemote(remote)">Disconnect</button>
</div>
<div v-else class="view-mode">
<input
type="text"
v-model="remote.name"
placeholder="Enter remote name..."
class="input-field"
/>
<button class="btn btn-primary" @click="connectLocalRemote(remote)">Connect</button>
</div>
<button class="btn btn-danger" @click="removeLocalRemote(remote)">Delete</button>
</div>
</div>
<button class="btn btn-primary" @click="createLocalRemote">+ Create Local Remote</button>
</section>
</main>
</template>
<style scoped>
.remote-list {
margin-top: 1rem;
}
/* ---------- Shared utilities ---------- */
.section { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--color-border); }
.section-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; }
.count-badge { font-size: 0.8em; color: var(--color-text-mute); background: var(--color-background-muted); padding: 2px 8px; border-radius: 10px; }
.empty-state { padding: 1em; color: var(--color-text-mute); }
.remote-entry {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
margin-bottom: 1rem;
background-color: var(--color-background-soft);
}
/* ---------- Card layout ---------- */
.card { border: 1px solid var(--color-border); border-radius: var(--border-radius); margin-bottom: 0.75rem; background: var(--color-background-soft); }
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; cursor: pointer; }
.header-left { display: flex; align-items: center; gap: 0.5rem; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
.status-dot.active { background: #22c55e; box-shadow: 0 0 4px rgba(34,197,94,0.5); }
.status-text { font-size: 0.85em; color: var(--color-text-mute); }
.expand-icon { color: var(--color-text-mute); }
.card-body { padding: 0 1rem 1rem; }
.functions-label { font-weight: bold; font-size: 0.85em; color: var(--color-text-heading); margin-bottom: 0.5rem; }
.view-mode {
display: flex;
align-items: center;
gap: 1rem;
flex-grow: 1;
}
/* ---------- Function grid ---------- */
.func-grid { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.func-card { text-align: left; border: 1px solid var(--color-border); border-radius: var(--border-radius); padding: 0.5rem 0.75rem; cursor: pointer; background: var(--color-background); transition: all 0.15s ease; }
.func-card:hover { background: var(--color-background-hover); }
.func-card.active { border-color: var(--color-primary); background: var(--color-primary-light, rgba(79,70,229,0.1)); }
.func-name { font-weight: bold; font-family: monospace; font-size: 0.9em; }
.func-desc { font-size: 0.8em; color: var(--color-text-mute); margin-top: 2px; }
.status-text {
font-weight: bold;
}
/* ---------- Call panel ---------- */
.call-layout { display: flex; gap: 1rem; }
.call-inputs, .call-output { flex: 1; }
.call-inputs label { display: block; font-weight: bold; margin-bottom: 0.25rem; }
.call-inputs textarea { width: 100%; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); resize: vertical; font-family: monospace; font-size: 0.85em; margin-bottom: 0.5rem; }
.output-block { margin-bottom: 0.75rem; }
.output-block.success pre { background: var(--color-success-bg, #dcfce7); padding: 0.5rem; border-radius: var(--border-radius); overflow-x: auto; }
.output-block.error { color: #ef4444; }
.output-block.error pre { background: var(--color-error-bg, #fee2e2); padding: 0.5rem; border-radius: var(--border-radius); overflow-x: auto; }
.output-placeholder { padding: 1em; color: var(--color-text-mute); font-style: italic; }
.input-field {
flex-grow: 1;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
}
/* ---------- Buttons (shared across sections) ---------- */
.btn { padding: 0.5em 1em; border-radius: var(--border-radius); border: none; cursor: pointer; font-weight: 500; }
.btn-primary { background: var(--color-primary, #4f46e5); color: #fff; }
.btn-secondary { background: var(--color-muted, #9ca3af); color: #fff; }
.btn-danger { background: var(--color-error, #ef4444); color: #fff; }
/* ---------- Local remotes (preserved from original) ---------- */
.remote-list { margin-top: 1rem; }
.remote-entry { display: flex; align-items: center; gap: 1rem; padding: 1rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); margin-bottom: 1rem; background-color: var(--color-background-soft); }
.view-mode { display: flex; align-items: center; gap: 1rem; flex-grow: 1; }
.status-text { font-weight: bold; }
.input-field { flex-grow: 1; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); }
</style>